diff --git a/e2e/testcafe-devextreme/tests/common/themeModes.ts b/e2e/testcafe-devextreme/tests/common/themeModes.ts new file mode 100644 index 000000000000..da4996fa0f41 --- /dev/null +++ b/e2e/testcafe-devextreme/tests/common/themeModes.ts @@ -0,0 +1,186 @@ +import { ClientFunction, Selector } from 'testcafe'; +import url from '../../helpers/getPageUrl'; +import { createWidget } from '../../helpers/createWidget'; +import { getComputedPropertyValue } from '../../helpers/domUtils'; +import { clearTestPage } from '../../helpers/testPageUtils'; +import { getFullThemeName, getThemeName } from '../../helpers/themeUtils'; + +/* + * The only place the mode classes are exercised in a real browser: jsdom does not inherit a custom + * property, and inheritance is the whole mechanism. Every assertion is relative - "this scope + * differs from that one", never a hex literal - so a token bump does not touch the test. + */ +if (getThemeName() === 'fluent-next') { + fixture`Theme modes` + .page(url(__dirname, '../container.html')) + .afterEach(async (t) => { await clearTestPage(t); }); + + const buildMode = getFullThemeName().includes('.dark') ? 'dark' : 'light'; + const oppositeMode = buildMode === 'dark' ? 'light' : 'dark'; + + // Roles the mode decides, one per family, plus the two system-tier names that used to freeze. + const MODE_DEPENDENT = ['--dxds-color-bg', '--dxds-color-content']; + const SYSTEM_TIER = ['--dx-global-content', '--dx-surface-overlay', '--dx-focus-rect-outline']; + + const render = ClientFunction((markup: string) => { + const container = document.querySelector('#container'); + + if (container) container.innerHTML = markup; + }); + + const valueAt = async (selector: string, property: string): Promise => ( + await getComputedPropertyValue(selector, property) + ).trim(); + + const reportedMode = ClientFunction((selector: string) => (window as any).DevExpress.ui.themes + .mode(document.querySelector(selector))); + + const setScopeMode = ClientFunction((selector: string, mode: string, tell: boolean) => { + const scope = document.querySelector(selector) as HTMLElement; + + scope.classList.remove('dx-theme-mode-light', 'dx-theme-mode-dark'); + scope.classList.add(`dx-theme-mode-${mode}`); + + if (tell) { + (window as any).DevExpress.ui.themes.refreshMode(); + } + }); + + test('a named mode class re-resolves the roles under it', async (t) => { + await render(` +
+
+
+ `); + + await t.expect(await valueAt('#plain', '--dx-theme-mode')).eql(buildMode); + await t.expect(await valueAt('#light', '--dx-theme-mode')).eql('light'); + await t.expect(await valueAt('#dark', '--dx-theme-mode')).eql('dark'); + + for (const role of MODE_DEPENDENT) { + const [light, dark, plain] = [ + await valueAt('#light', role), + await valueAt('#dark', role), + await valueAt('#plain', role), + ]; + + await t.expect(light) + .notEql(dark, `${role} must differ between the two named modes`); + await t.expect(plain) + .eql(buildMode === 'dark' ? dark : light, `${role} without a class is the bundle's mode`); + } + }); + + test('inverted flips against the nearest named scope', async (t) => { + await render(` +
+
+
+
+
+
+
+
+
+
+
+ `); + + await t.expect(await valueAt('#bare', '--dx-theme-mode')) + .eql(oppositeMode, 'with no named scope above it, inverted opposes the bundle'); + await t.expect(await valueAt('#in-dark', '--dx-theme-mode')).eql('light'); + await t.expect(await valueAt('#in-light', '--dx-theme-mode')).eql('dark'); + // The depth-3 case pins that the NEAREST scope is what is read, not the bundle: inside a dark + // block the pair resolves dark -> light -> dark, not light -> dark. + await t.expect(await valueAt('#nested', '--dx-theme-mode')) + .eql(buildMode, 'inverted inside inverted flips back'); + await t.expect(await valueAt('#nested-in-dark', '--dx-theme-mode')) + .eql('dark', 'the pair resolves against the dark scope around it'); + }); + + test('the system tier follows the mode instead of freezing at the bundle', async (t) => { + await render(`
`); + + for (const name of SYSTEM_TIER) { + // These are declared on the document root; a custom property resolves where it is declared, + // so without the mode classes on that same rule the value would stay the bundle's. + await t.expect(await valueAt('#probe', name)) + .notEql(await valueAt('html', name), `${name} must re-resolve inside a mode scope`); + } + }); + + test('themes.mode answers for the element, not for the loaded file', async (t) => { + await render(` +
+
+
+ `); + + // The element inherits the property from a scope above it, so only the cascade knows - which + // is why this case lives here and not next to themes.ts. + await t.expect(await reportedMode('#plain')).eql(buildMode, 'no scope above it - the loaded theme answers'); + await t.expect(await reportedMode('#scoped')).eql(oppositeMode, 'the mode is inherited from the scope, not declared here'); + await t.expect(await reportedMode('#back')).eql(buildMode, 'and inverted inside it flips back'); + + // The public answer and the property the theme publishes must not drift apart. + for (const id of ['#plain', '#scoped', '#back']) { + await t.expect(await reportedMode(id)).eql(await valueAt(id, '--dx-theme-mode')); + } + }); + + test('an overlay is painted in the mode of the element that owns it', async (t) => { + await render(`
`); + + await createWidget('dxPopup', { + visible: true, width: 100, height: 100, animation: null, + }, '#owner'); + + const wrapper = Selector('.dx-popup-wrapper'); + + await t.expect(wrapper.exists).ok(); + await t.expect(await valueAt('.dx-popup-wrapper', '--dx-theme-mode')).eql(oppositeMode); + await t.expect(await valueAt('.dx-popup-wrapper', '--dxds-color-bg')) + .eql(await valueAt('#owner', '--dxds-color-bg'), 'the overlay resolves the same roles as its owner'); + }); + + test('an open overlay follows its scope once the application says the mode changed', async (t) => { + /* + * The scope has to be LOCAL: a page-level switch reaches the container through the cascade on + * its own, so that version of this case passes with the subscription removed. + */ + await render(`
`); + + await createWidget('dxPopup', { + visible: true, width: 100, height: 100, animation: null, + }, '#owner'); + + const painted = async (): Promise => valueAt('.dx-popup-wrapper', '--dxds-color-bg'); + const asOpened = await painted(); + + await t.expect(asOpened).eql(await valueAt('#owner', '--dxds-color-bg'), 'opens in the mode of its scope'); + + await setScopeMode('#scope', buildMode, true); + + await t.expect(await valueAt('#owner', '--dx-theme-mode')).eql(buildMode, 'the scope did switch'); + await t.expect(await painted()).notEql(asOpened, 'the open overlay repainted'); + await t.expect(await painted()) + .eql(await valueAt('#owner', '--dxds-color-bg'), 'and matches its owner again'); + }); + + test('an open overlay keeps its mode until the application says so', async (t) => { + await render(`
`); + + await createWidget('dxPopup', { + visible: true, width: 100, height: 100, animation: null, + }, '#owner'); + + const asOpened = await valueAt('.dx-popup-wrapper', '--dxds-color-bg'); + + // A class moved by the application is invisible to us; this pins that we do not pretend + // otherwise - the overlay waits to be told. + await setScopeMode('#scope', buildMode, false); + + await t.expect(await valueAt('.dx-popup-wrapper', '--dxds-color-bg')) + .eql(asOpened, 'still painted in the mode it was opened in'); + }); +} diff --git a/packages/devextreme-scss/build/tokens/build-tokens.mjs b/packages/devextreme-scss/build/tokens/build-tokens.mjs index 750f5b0a4d45..cab61187cd6d 100644 --- a/packages/devextreme-scss/build/tokens/build-tokens.mjs +++ b/packages/devextreme-scss/build/tokens/build-tokens.mjs @@ -1,8 +1,11 @@ import path from 'node:path'; import url from 'node:url'; import { createRequire } from 'node:module'; -import { readdir, readFile, rm } from 'node:fs/promises'; +import { + readdir, readFile, rm, writeFile, +} 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 +178,12 @@ 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 MODE_ALIASES_FILE = 'mode-aliases'; +const MODE_SHARED_FILE = 'mode-shared'; + const themePath = path.resolve(dirname, `../../scss/widgets/${THEME_FOLDER}`); const FLUENT_PALETTES = [ @@ -231,6 +240,39 @@ const getModeFiles = (mode) => [ // properties. Absent from the bridge, `ds.$button-color-bg-rest` is now a Sass error. const getBridgeFiles = () => getModeFiles('light'); +/* + * A bundle needs the mode-dependent declarations under three selectors, and 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 where it wants. + * + * 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 +357,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 +370,34 @@ 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 layers that read a colour role without being one: box-shadow composites and the global + * focus aliases. Their text is mode-independent, but a custom property resolves where it is + * declared, so on `:root` they would freeze at the bundle's mode. Both mode configs emit this + * file; the sources are the same, so the two writes are byte-identical. + */ + { + destination: `${THEME_NAME}/${MODE_ALIASES_FILE}.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 }, }, ]); @@ -413,6 +465,89 @@ async function collectThemeStyleSheets() { .map((entry) => path.join(entry.parentPath, entry.name)); } +/* + * Emitting by source file is a coarse answer: many declarations in those files do not depend on + * the mode, and repeating them in four scopes per bundle is pure weight. Which is which is derived + * from the generated text - a name whose two mode values differ, plus anything reading such a name + * through a chain - so a token that starts or stops depending on the mode moves on its own at the + * next bump. The remainder goes to a plain `:root` block; the theme-mode-scope gate is the judge. + * The counts are printed at the end of the build. + */ +const DECLARATION = /^(\s*)(--[\w-]+)\s*:\s*([^;]+);\s*$/; + +const parseDeclarations = (content) => content.split('\n').reduce((declarations, line) => { + const match = DECLARATION.exec(line); + + return match ? declarations.set(match[2], match[3].trim()) : declarations; +}, new Map()); + +const readsOf = (value) => [...value.matchAll(/var\(\s*(--[\w-]+)/g)].map(([, name]) => name); + +const modeDependentNames = (light, dark, aliases) => { + // Both key sets, not just the light one: a name only one mode declares differs by definition, + // and seeding from one side would drop it from the other scope without moving it to :root. + const tainted = new Set([...light.keys(), ...dark.keys()] + .filter((name) => light.get(name) !== dark.get(name))); + + for (let grew = true; grew;) { + grew = false; + + for (const source of [light, aliases]) { + for (const [name, value] of source) { + if (!tainted.has(name) && readsOf(value).some((read) => tainted.has(read))) { + tainted.add(name); + grew = true; + } + } + } + } + + return tainted; +}; + +const withBody = (content, keep) => content.replace( + /(\{\n)([\s\S]*)(\n\})/, + (whole, open, body, close) => { + const lines = body.split('\n').filter((line) => { + const match = DECLARATION.exec(line); + + return !match || keep(match[2]); + }); + + return `${open}${lines.join('\n')}${close}`; + }, +); + +async function splitModeScopedLayers() { + const modeFile = (mode) => path.join(buildPath, THEME_NAME, 'semantic', 'colors', `${mode}.scss`); + const aliasesFile = path.join(buildPath, THEME_NAME, `${MODE_ALIASES_FILE}.scss`); + const sharedFile = path.join(buildPath, THEME_NAME, `${MODE_SHARED_FILE}.scss`); + + const sources = Object.fromEntries(await Promise.all( + [['light', modeFile('light')], ['dark', modeFile('dark')], ['aliases', aliasesFile]] + .map(async ([key, file]) => [key, { file, content: await readFile(file, 'utf-8') }]), + )); + const parsed = Object.fromEntries( + Object.entries(sources).map(([key, { content }]) => [key, parseDeclarations(content)]), + ); + const dependent = modeDependentNames(parsed.light, parsed.dark, parsed.aliases); + + await Promise.all(Object.values(sources).map(({ file, content }) => writeFile( + file, + withBody(content, (name) => dependent.has(name)), + 'utf-8', + ))); + + // Either mode file carries the shared roles: for those, light and dark agree by definition. + const shared = [...parsed.light, ...parsed.aliases].filter(([name]) => !dependent.has(name)); + const header = sources.light.content.slice(0, sources.light.content.indexOf('@mixin')); + const body = shared.map(([name, value]) => ` ${name}: ${value};`).join('\n'); + + await writeFile(sharedFile, `${header}:root {\n${body}\n}\n`, 'utf-8'); + + return { dependent: dependent.size, shared: shared.length }; +} + // Every token a widget reads must still exist in the package. Without this a deleted token surfaces // much later as a Sass "Undefined variable", one name per rebuild, with no hint that a bump caused // it. Read from the flat index, not the bridge: it carries the version for the message. @@ -469,10 +604,12 @@ async function build() { await sd.buildAllPlatforms(); } + const split = await splitModeScopedLayers(); const fileCount = await validateReferences(); const consumedCount = await validateConsumedTokens(); console.log(`Design tokens generated: ${fileCount} files in ${buildPath}`); + console.log(`Mode-scoped declarations: ${split.dependent} depend on the mode, ${split.shared} moved to :root`); console.log(`Design tokens consumed by ${THEME_FOLDER}: ${consumedCount} verified against the package`); } 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 64e7058da8d3..4f1f3ae5a2bd 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 !default; @@ -11,10 +14,79 @@ $accent: colors.$color !default; * 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. + * + * Loaded here is what does not depend on the colour mode; the rest goes through `mode-values`. + * Which is which is derived by build/tokens/build-tokens.mjs, so this file carries no list. */ @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}"); +@include meta.load-css("../../_design-system/fluent/mode-shared"); + +/* + * `--dx-theme-mode` is here for the JS: `dx-theme-mode-inverted` means "the opposite of my + * surroundings", so no reading of ancestor classes tells you which mode an element ended up in. + * Overlays are reparented to the viewport and have to be given the mode their owner resolved to, + * so `core/utils/theme_mode.ts` asks the browser for this property instead. + */ +@mixin mode-values($mode) { + --dx-theme-mode: #{$mode}; + + @if $mode == "light" { + @include light-roles.roles(); + } @else { + @include dark-roles.roles(); + } + + @include mode-aliases.aliases(); +} + +/* + * 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. + */ +@mixin named-scopes($own, $other) { + :root, + .dx-theme-mode-#{$own} { + @include mode-values($own); + } + + .dx-theme-mode-#{$other} { + @include mode-values($other); + } +} + +/* + * A style query, not a descendant selector: it is evaluated against the NEAREST ancestor, which is + * what "inverted" means. A selector can only ask for ANY ancestor, so `dark > light > inverted` + * resolved against the dark rather than the light next to it, and nesting did not compose. + * + * Where style queries are unsupported these blocks are dropped and an inverted island renders as + * its surroundings instead of the opposite of them - still a correctly painted scope that + * `--dx-theme-mode` describes, so the JS keeps agreeing with the screen. + */ +@mixin inverted-scope() { + @container style(--dx-theme-mode: light) { + .dx-theme-mode-inverted { + @include mode-values("dark"); + } + } + + @container style(--dx-theme-mode: dark) { + .dx-theme-mode-inverted { + @include mode-values("light"); + } + } +} + +@if colors.$mode != "light" and colors.$mode != "dark" { + @error "fluent-next: unknown colour mode #{meta.inspect(colors.$mode)}; expected \"light\" or \"dark\"."; +} + +/* + * Inverted first: a named class on the SAME element has to win, and at equal weight source order + * is what decides. + */ +@include inverted-scope(); +@include named-scopes(colors.$mode, if(colors.$mode == "light", "dark", "light")); 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 a9d74d264c2b..daeca41ada26 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/_public-tier.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/_public-tier.scss @@ -77,7 +77,10 @@ @use "validation/public" as validationPublic; @use "widget/public" as widgetPublic; -:root { +: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 d3fba875ecf7..e42c04b0113e 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/diagram/_index.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/diagram/_index.scss @@ -435,7 +435,7 @@ .dx-dropdowneditor-field-before-template-wrapper { .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 dda5d4cf38e4..710f46e6ba04 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/gridBase/_colors.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/gridBase/_colors.scss @@ -66,7 +66,10 @@ $grid-text-stub-bg: rgb(from #{ds.$color-bg-inverted} r g b / 0.1) !default; // $grid-filter-panel-content: ds.$color-content-primary !default; $grid-draggable-column-content: ds.$color-content !default; -:root { +: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 d8410e8df25f..7564f5afaa29 100644 --- a/packages/devextreme-scss/scss/widgets/fluent-next/textEditor/_colors.scss +++ b/packages/devextreme-scss/scss/widgets/fluent-next/textEditor/_colors.scss @@ -32,7 +32,10 @@ $text-editor-content-disabled: ds.$color-content-disabled !default; $text-editor-label-content-focused: ds.$color-content-primary !default; -:root { +: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/data-uri-static-markers.test.ts b/packages/devextreme-scss/tests/data-uri-static-markers.test.ts index 808eecb1ff2d..5b7ac3857ee0 100644 --- a/packages/devextreme-scss/tests/data-uri-static-markers.test.ts +++ b/packages/devextreme-scss/tests/data-uri-static-markers.test.ts @@ -6,7 +6,9 @@ * scss/widgets/fluent-next/DIVERGENCES.md for why the literals exist at all. * * The token side is read from the built bundles — the `test` target depends on `build:themes`, so - * they are fresh here, and a missing bundle fails loudly rather than passing on an empty scan. + * they are fresh here, and a missing bundle fails loudly rather than passing on an empty scan. Only + * the `:root` scope of a bundle counts: the mode classes carry the opposite mode's values, and a + * baked literal answers for a page that names no mode. * * There is nothing to regenerate: a failure means either the literal or the marker is wrong, and * which one it is has to be decided by looking at the token. @@ -67,18 +69,34 @@ const expand = (hex: string): string => { : value; }; -function resolve(css: string, property: string, depth = 0): string | null { - const found = new RegExp(`${property.replace(/-/g, '\\-')}:([^;}]*)`).exec(css); - if (!found) return null; - const value = found[1].trim(); +/* + * A bundle declares each role more than once: the mode it was built for sits on `:root`, and the + * opposite mode sits on the `dx-theme-mode-*` classes. A literal baked into + * a data-uri is what a page with no mode class shows, so only the `:root` scope may answer here — + * scanning the whole text would hand back whichever block happens to come first. + */ +const rootDeclarations = (css: string): Map => { + const declarations = new Map(); + for (const [, selector, body] of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { + if (!selector.split(',').some((one) => one.trim() === ':root')) continue; + for (const [, property, value] of body.matchAll(/(--[a-z0-9-]+):([^;]*)/g)) { + declarations.set(property, value.trim()); + } + } + return declarations; +}; + +function resolve(declarations: Map, property: string, depth = 0): string | null { + const value = declarations.get(property); + if (value === undefined) return null; const indirect = /^var\((--[a-z0-9-]+)\)$/.exec(value); - return indirect && depth < 8 ? resolve(css, indirect[1], depth + 1) : value; + return indirect && depth < 8 ? resolve(declarations, indirect[1], depth + 1) : value; } -const bundles: Record = {}; +const bundles: Record> = {}; for (const mode of ['light', 'dark']) { const path = join(artifactsCss, `dx.fluent-next.blue.${mode}.css`); - if (existsSync(path)) bundles[mode] = readFileSync(path, 'utf8'); + if (existsSync(path)) bundles[mode] = rootDeclarations(readFileSync(path, 'utf8')); } test('every dx-data-uri-static literal still equals the token it names', () => { diff --git a/packages/devextreme-scss/tests/fluent-next-naming.baseline.json b/packages/devextreme-scss/tests/fluent-next-naming.baseline.json index ccd35e5d208c..f24a79b21b2b 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/fluent-next-naming.test.ts b/packages/devextreme-scss/tests/fluent-next-naming.test.ts index 441e07867f05..76fa000e89f5 100644 --- a/packages/devextreme-scss/tests/fluent-next-naming.test.ts +++ b/packages/devextreme-scss/tests/fluent-next-naming.test.ts @@ -38,6 +38,14 @@ const themeRoot = join(widgetsRoot, 'fluent-next'); // Labels a stylesheet for error messages: `fluent-next/common/_mixins.scss`. const sourceLabel = (file: string): string => file.slice(widgetsRoot.length + 1); +/* + * A style query names a custom property in its condition (`@container style(--dx-theme-mode: dark)`) + * and looks exactly like a declaration to a `--dx-…:` match. Preludes hold none, so dropping them + * is safe; the reads themselves are covered by the resolve case below. + */ +const declarationBody = (content: string, label: string): string => stripScssComments(content, label) + .replace(/@[a-z-]+[^;{]*\{/g, '{'); + // The --dx-* component tier (see the "wave F" test block and NAMING.md): generated projections in // _public.scss, hand-written links in _public-links.scss, and the collector that mounts them. const isPublicManifestFile = (file: string): boolean => file.endsWith('_public.scss') @@ -477,7 +485,7 @@ const findings = { */ publicTierManualDeclarations: walk(themeRoot, '.scss') .filter((file) => !isPublicTierFile(file)) - .flatMap((file) => [...stripScssComments(readFileSync(file, 'utf8'), sourceLabel(file)) + .flatMap((file) => [...declarationBody(readFileSync(file, 'utf8'), sourceLabel(file)) .matchAll(/(--dx-[a-z0-9-]+)\s*:/g)] .map((match) => `${sourceLabel(file)}: ${match[1]}`)) .sort(), @@ -940,23 +948,34 @@ test('component tier: the collector matches registries.rootSelectors exactly', ( }).toEqual({ offenders: [], includedTwice: [], notIncluded: [], unknownNamespace: [] }); }); -test('component tier: every var(--dx-…) read in the theme resolves to a declared name', () => { +test('component tier: every --dx-… read in the theme resolves to a declared name', () => { /* * stylelint does not ban the FORM (the tier is consumed through it) — this is the check that * took over: a read anywhere in fluent-next must hit the tier, the legacy surface, or the JS * runtime contract. A typo'd custom property compiles and dies silently at computed-value time; * this fails the build instead. + * + * `var()` is not the only way to read one: a style query names the property in its condition + * (`@container style(--dx-theme-mode: dark)`), and a typo there is even quieter — the block + * simply never matches, so the rules inside it go missing rather than losing one value. */ const declared = new Set([ ...[...tierDeclared.keys()].map((variable) => `--dx-${variable.slice(1)}`), ...RUNTIME_CONTRACT, ...findings.publicTierManualDeclarations.map((entry) => entry.slice(entry.indexOf(': ') + 2)), ]); - const offenders = walk(themeRoot, '.scss').flatMap((file) => [ - ...stripScssComments(readFileSync(file, 'utf8'), sourceLabel(file)).matchAll(/var\(\s*(--dx-[a-z0-9-]+)/g), - ].map((match) => match[1]) - .filter((name) => !declared.has(name)) - .map((name) => `${sourceLabel(file)}: var(${name}) resolves to no declared --dx name`)); + const READS = [ + { pattern: /var\(\s*(--dx-[a-z0-9-]+)/g, form: (name: string): string => `var(${name})` }, + { pattern: /style\(\s*(--dx-[a-z0-9-]+)/g, form: (name: string): string => `style(${name}: …)` }, + ]; + const offenders = walk(themeRoot, '.scss').flatMap((file) => { + const content = stripScssComments(readFileSync(file, 'utf8'), sourceLabel(file)); + + return READS.flatMap(({ pattern, form }) => [...content.matchAll(pattern)] + .map((match) => match[1]) + .filter((name) => !declared.has(name)) + .map((name) => `${sourceLabel(file)}: ${form(name)} resolves to no declared --dx name`)); + }); expect(offenders).toEqual([]); }); 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..d12e940d2bf5 --- /dev/null +++ b/packages/devextreme-scss/tests/theme-mode-scope.test.ts @@ -0,0 +1,132 @@ +/* + * Gate for the fluent-next theme-mode invariant: an element carrying `dx-theme-mode-light`, + * `-dark` or `-inverted` repaints itself and its subtree. + * + * It breaks 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 and freezes + * at the bundle's mode, whatever class sits below. The declaration stays valid and only the colour + * is wrong, so nothing fails - 39 properties were in that state before this gate existed. + * + * Checked against the built bundle, not a list here: the three scopes declare the same names, and + * nothing reading one of those names is declared where a mode class cannot reach it. A declaration + * on a component root is fine and not flagged - that element may itself sit inside a scope. + */ + +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)); + +// A rule can reach the root while also matching something else: `:root, .dx-button { … }` still +// declares on . So the question is whether ANY selector is the root, with no mode scope in +// the same list to re-resolve it. +const freezesOnDocumentRoot = (selectors: string[]): boolean => selectors.some(isDocumentRoot) + && !selectors.some((selector) => modeScopesOf(selector).length); + +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 = freezesOnDocumentRoot(rule.selectors); + + 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 something a mode class redefines, whether +// directly or through another such declaration - `box-shadow-md` over `color-shadow-key`. +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 38f8bea15214..fc6d0a203732 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: { @@ -198,8 +201,13 @@ 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, because a `:root`-only alias onto a role resolves once, on , and would + * freeze at the bundle's mode. The component tier needs no entry: its roots sit inside the + * mode scope already. + */ + 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: @@ -377,7 +385,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 a90ae0e1b591..43477de92dbe 100644 --- a/packages/devextreme-scss/tools/naming/registries.json +++ b/packages/devextreme-scss/tools/naming/registries.json @@ -352,7 +352,10 @@ ".dx-gallery" ], "typography": [ - ":root" + ":root", + ".dx-theme-mode-light", + ".dx-theme-mode-dark", + ".dx-theme-mode-inverted" ], "toolbar": [ ".dx-toolbar", @@ -566,7 +569,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..f34102aa58ea --- /dev/null +++ b/packages/devextreme/js/__internal/core/utils/__tests__/swatch_container.test.ts @@ -0,0 +1,205 @@ +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; } + .mode-sepia { --dx-theme-mode: sepia; } +`; + +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); + }); + + // The contract is light or dark; anything else names no scope the theme can paint, and a + // container carrying it would be a wrapper no rule matches. + it('carries no mode when the value names 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..53685c4f44fd 100644 --- a/packages/devextreme/js/__internal/core/utils/swatch_container.ts +++ b/packages/devextreme/js/__internal/core/utils/swatch_container.ts @@ -1,29 +1,94 @@ import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import { value } from '@js/core/utils/view_port'; +import { resolvedThemeMode } from '@ts/core/utils/theme_mode'; const SWATCH_CONTAINER_CLASS_PREFIX = 'dx-swatch-'; +const THEME_MODE_CLASS_PREFIX = '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 container is reparented to the viewport, where the surroundings are different ones, so the +// class has to name the mode the cascade resolved rather than the one the element wears. +const themeModeClasses = ($element: dxElementWrapper): string[] => { + const mode = resolvedThemeMode($element); + + 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/core/utils/theme_mode.ts b/packages/devextreme/js/__internal/core/utils/theme_mode.ts new file mode 100644 index 000000000000..5a74dca286c0 --- /dev/null +++ b/packages/devextreme/js/__internal/core/utils/theme_mode.ts @@ -0,0 +1,28 @@ +import type { dxElementWrapper } from '@js/core/renderer'; +import $ from '@js/core/renderer'; +import { getWindow, hasWindow } from '@js/core/utils/window'; + +export type ThemeMode = 'light' | 'dark'; + +/* + * Published by a theme on every mode scope it declares. `dx-theme-mode-inverted` asks for the + * opposite of its surroundings, so ancestor classes never answer which mode an element ended up + * in - only the cascade does, and this property is where it says so. + */ +export const THEME_MODE_PROPERTY = '--dx-theme-mode'; + +/** The mode an element resolves to; null when nothing declared one, or named no mode. */ +export function resolvedThemeMode( + element: Element | dxElementWrapper, +): ThemeMode | null { + const node = $(element).get(0); + const window = hasWindow() ? getWindow() : undefined; + + if (!node || !window?.getComputedStyle) { + return null; + } + + const declared = window.getComputedStyle(node).getPropertyValue(THEME_MODE_PROPERTY).trim(); + + return declared === 'light' || declared === 'dark' ? declared : null; +} diff --git a/packages/devextreme/js/__internal/ui/__tests__/themes.test.ts b/packages/devextreme/js/__internal/ui/__tests__/themes.test.ts new file mode 100644 index 000000000000..e6cbf4630747 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/__tests__/themes.test.ts @@ -0,0 +1,92 @@ +import { + afterEach, beforeEach, describe, expect, it, +} from '@jest/globals'; +import $ from '@js/core/renderer'; +import { themeModeChangedCallback } from '@ts/ui/m_themes_callback'; +import { mode, refreshMode, resetTheme } from '@ts/ui/themes'; + +/* + * jsdom resolves a custom property declared ON an element but does not inherit it, so these cases + * name the mode at the element `mode()` reads it from. Inheritance - a scope declaring the property + * and a descendant resolving it through the cascade, which is how the mechanism actually works - is + * covered in a real browser by e2e/testcafe-devextreme/tests/common/themeModes.ts. + */ +const style = (css: string): void => { document.head.innerHTML = ``; }; + +const withThemeMarker = (themeName: string): string => `.dx-theme-marker { font-family: "dx.${themeName}"; }`; + +describe('themes.mode', () => { + let element = document.createElement('div'); + + beforeEach(() => { + element = document.createElement('div'); + element.className = 'probe'; + document.body.appendChild(element); + }); + + afterEach(() => { + document.head.innerHTML = ''; + document.body.innerHTML = ''; + resetTheme(); + }); + + it('reads the mode the element resolves to', () => { + style('.probe { --dx-theme-mode: dark; }'); + + expect(mode(element)).toBe('dark'); + }); + + it('accepts a renderer wrapper as well as an element', () => { + style('.probe { --dx-theme-mode: dark; }'); + + expect(mode($(element))).toBe('dark'); + }); + + it('falls back to the loaded theme when nothing declares a mode', () => { + style(withThemeMarker('generic.dark')); + + expect(mode(element)).toBe('dark'); + }); + + it('answers light for a loaded theme that is not dark', () => { + style(withThemeMarker('generic.light')); + + expect(mode(element)).toBe('light'); + }); + + it('lets the element outrank the loaded theme', () => { + style(`${withThemeMarker('generic.dark')} .probe { --dx-theme-mode: light; }`); + + expect(mode(element)).toBe('light'); + }); + + it('ignores a value that names no mode', () => { + style(`${withThemeMarker('generic.dark')} .probe { --dx-theme-mode: sepia; }`); + + expect(mode(element)).toBe('dark'); + }); + + it('answers for a detached element instead of throwing', () => { + style(withThemeMarker('generic.dark')); + + expect(mode(document.createElement('div'))).toBe('dark'); + }); +}); + +describe('themes.refreshMode', () => { + it('announces that a resolved mode may have changed', () => { + const told: number[] = []; + const subscriber = (): void => { told.push(1); }; + + themeModeChangedCallback.add(subscriber); + + try { + refreshMode(); + refreshMode(); + + expect(told).toHaveLength(2); + } finally { + themeModeChangedCallback.remove(subscriber); + } + }); +}); diff --git a/packages/devextreme/js/__internal/ui/m_themes_callback.ts b/packages/devextreme/js/__internal/ui/m_themes_callback.ts index 8a5897072ef2..08f9f243d539 100644 --- a/packages/devextreme/js/__internal/ui/m_themes_callback.ts +++ b/packages/devextreme/js/__internal/ui/m_themes_callback.ts @@ -1,3 +1,10 @@ import Callbacks from '@js/core/utils/callbacks'; export const themeReadyCallback = Callbacks(); + +/* + * Fired by `themes.refreshMode()`. Anything rendered outside the scope it belongs to - today that + * is open overlays, which live in the viewport - re-reads its mode here. It sits in this module so + * that neither themes.ts nor the overlay has to import the other. + */ +export const themeModeChangedCallback = Callbacks(); diff --git a/packages/devextreme/js/__internal/ui/overlay/overlay.ts b/packages/devextreme/js/__internal/ui/overlay/overlay.ts index e1a18de96da5..c196a7d6cf76 100644 --- a/packages/devextreme/js/__internal/ui/overlay/overlay.ts +++ b/packages/devextreme/js/__internal/ui/overlay/overlay.ts @@ -46,6 +46,7 @@ import windowUtils from '@ts/core/utils/m_window'; import type { OptionChanged } from '@ts/core/widget/types'; import type { SupportedKeys } from '@ts/core/widget/widget'; import Widget from '@ts/core/widget/widget'; +import { themeModeChangedCallback } from '@ts/ui/m_themes_callback'; import type { BaseControllerProperties, ControllerOverlayElements, @@ -273,6 +274,8 @@ class Overlay< _viewPortChangeHandle?: () => void; + _themeModeChangeHandle?: () => void; + _proxiedDocumentDownHandler?: EventHandler; _supportedKeys(): SupportedKeys { @@ -414,6 +417,7 @@ class Overlay< this._$wrapper.attr('data-bind', 'dxControlsDescendantBindings: true'); this._toggleViewPortSubscription(true); + this._toggleThemeModeSubscription(true); const { hideTopOverlayHandler } = this.option(); @@ -631,6 +635,56 @@ class Overlay< this._refresh(); } + _toggleThemeModeSubscription(toggle: boolean): void { + if (this._themeModeChangeHandle) { + themeModeChangedCallback.remove(this._themeModeChangeHandle); + } + + if (toggle) { + this._themeModeChangeHandle = (): void => { + this._themeModeChangeHandler(); + }; + + themeModeChangedCallback.add(this._themeModeChangeHandle); + } + } + + /* + * The container carries the mode its owner resolved to when the overlay was shown, and nothing + * re-picks it afterwards: `_moveToContainer` runs on becoming visible and on a content + * re-render, and a class moving up the tree is neither. + */ + _themeModeChangeHandler(): void { + if (!this._isVisible()) { + return; + } + + // Move only if the scope named a different node: appending is not a no-op for a child already + // in place, and the detach takes the focus out, restarts animations and reloads any iframe. + const { $container } = this._positionController; + const wrapper = this._$wrapper?.get(0) as HTMLElement | undefined; + + if (!$container || $container.get(0) === wrapper?.parentElement) { + return; + } + + /* + * The move detaches the wrapper, and a detached element loses the focus for good - the browser + * does not hand it back on re-insert. Unlike a container the application changed on one named + * overlay, this runs on every open overlay at once, because an application announced that a + * class moved somewhere; taking the caret out of whatever the user was typing in is not part + * of that. The selection survives on the element itself, so restoring the focus is enough. + */ + const focused = domAdapter.getActiveElement(wrapper) as HTMLElement | null; + const shouldRestoreFocus = !!wrapper && !!focused && domUtils.contains(wrapper, focused); + + this._moveToContainer(); + + if (shouldRestoreFocus) { + focused?.focus(); + } + } + _renderWrapperAttributes(): void { const { wrapperAttr } = this.option(); @@ -1583,6 +1637,7 @@ class Overlay< } this._toggleViewPortSubscription(false); + this._toggleThemeModeSubscription(false); this._toggleSubscriptions(false); this._updateZIndexStackPosition(false); diff --git a/packages/devextreme/js/__internal/ui/overlay/overlay_position_controller.ts b/packages/devextreme/js/__internal/ui/overlay/overlay_position_controller.ts index cc3bef9acf77..a08290530ceb 100644 --- a/packages/devextreme/js/__internal/ui/overlay/overlay_position_controller.ts +++ b/packages/devextreme/js/__internal/ui/overlay/overlay_position_controller.ts @@ -173,7 +173,8 @@ export class OverlayPositionController< } get $container(): dxElementWrapper | undefined { - // NOTE: swatch classes can be updated runtime + // Resolved on every read: the swatch and the theme mode an element sits in can both change at + // runtime, and an overlay shown afterwards has to land in the scope that holds then. this.updateContainer(); return this._$markupContainer; 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, diff --git a/packages/devextreme/js/__internal/ui/themes.ts b/packages/devextreme/js/__internal/ui/themes.ts index 537b6e7f29a9..ff49c99cf5b8 100644 --- a/packages/devextreme/js/__internal/ui/themes.ts +++ b/packages/devextreme/js/__internal/ui/themes.ts @@ -11,7 +11,8 @@ import { changeCallback, originalViewPort, value as viewPortValue } from '@js/co import { getWindow, hasWindow } from '@js/core/utils/window'; import errors from '@js/ui/widget/ui.errors'; import { uiLayerInitialized } from '@ts/core/utils/m_common'; -import { themeReadyCallback } from '@ts/ui/m_themes_callback'; +import { resolvedThemeMode } from '@ts/core/utils/theme_mode'; +import { themeModeChangedCallback, themeReadyCallback } from '@ts/ui/m_themes_callback'; const window = getWindow(); const ready = readyCallbacks.add; @@ -350,13 +351,11 @@ export function init(options): void { current(options); } -function isTheme(themeRegExp: string, themeName: string): boolean { - if (!themeName) { - // eslint-disable-next-line no-param-reassign - themeName = currentThemeName || readThemeMarker(); - } +function isTheme(themeRegExp: string, themeName?: string): boolean { + // Omitted on purpose by callers that ask about the loaded theme rather than about a given name. + const name: string | null = themeName || currentThemeName || readThemeMarker(); - return new RegExp(themeRegExp).test(themeName); + return !!name && new RegExp(themeRegExp).test(name); } export function isMaterial(themeName: string): boolean { @@ -375,7 +374,7 @@ export function isGeneric(themeName: string): boolean { return isTheme('generic', themeName); } -export function isDark(themeName: string): boolean { +export function isDark(themeName?: string): boolean { return isTheme('dark', themeName); } @@ -383,6 +382,28 @@ export function isCompact(themeName: string): boolean { return isTheme('compact', themeName); } +/** + * The colour mode an element is rendered in. `current()` and `isDark()` answer for the loaded + * stylesheet; a theme that ships both modes in one bundle has an answer per place instead, and + * publishes it in `--dx-theme-mode`. A theme that scopes no modes declares nothing, and the + * loaded theme answers. + */ +export function mode(element: Element | dxElementWrapper): 'light' | 'dark' { + return resolvedThemeMode(element) ?? (isDark() ? 'dark' : 'light'); +} + +/** + * Re-reads the colour mode for widgets rendered outside the element they belong to - today that is + * open overlays, which live in the viewport. Call it after moving a `dx-theme-mode-*` class or + * switching the theme. + * + * Deliberately not called from `current()`: a theme switch swaps one ``'s href, so the new + * stylesheet lands after the call returns and any firing from inside would read stale values. + */ +export function refreshMode(): void { + themeModeChangedCallback.fire(); +} + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types function themeReady(callback): void { themeReadyCallback.add(callback); @@ -511,6 +532,8 @@ export default { isMaterial, isFluent, isMaterialBased, + mode, + refreshMode, detachCssClasses, attachCssClasses, current, diff --git a/packages/devextreme/js/ui/themes.d.ts b/packages/devextreme/js/ui/themes.d.ts index c4cf17bed007..628ff7fe3c8c 100644 --- a/packages/devextreme/js/ui/themes.d.ts +++ b/packages/devextreme/js/ui/themes.d.ts @@ -1,3 +1,5 @@ +import { UserDefinedElement } from '../core/element'; + /** * @docid ui.themes * @namespace DevExpress.ui @@ -33,6 +35,22 @@ export default class themes { * @public */ static initialized(callback: Function): void; + /** + * @docid ui.themes.mode + * @publicName mode(element) + * @param1 element:Element|jQuery + * @return String + * @static + * @public + */ + static mode(element: UserDefinedElement): 'light' | 'dark'; + /** + * @docid ui.themes.refreshMode + * @publicName refreshMode() + * @static + * @public + */ + static refreshMode(): void; } export function current(): string; @@ -41,3 +59,6 @@ export function isFluent(theme: string): boolean; export function isMaterial(theme: string): boolean; export function isGeneric(theme: string): boolean; export function isCompact(theme: string): boolean; + +export function mode(element: UserDefinedElement): 'light' | 'dark'; +export function refreshMode(): void; diff --git a/packages/devextreme/js/ui/themes.js b/packages/devextreme/js/ui/themes.js index 7ba25bcc98a7..98dbc2a4fd5b 100644 --- a/packages/devextreme/js/ui/themes.js +++ b/packages/devextreme/js/ui/themes.js @@ -14,6 +14,8 @@ export const { isMaterial, isFluent, isMaterialBased, + mode, + refreshMode, detachCssClasses, attachCssClasses, current, diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/overlay.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/overlay.tests.js index 44d0d3583082..61e7e14f9eee 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/overlay.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/overlay.tests.js @@ -20,6 +20,7 @@ import * as zIndex from '__internal/ui/overlay/z_index'; import 'ui/scroll_view/ui.scrollable'; import selectors from '__internal/core/utils/m_selectors'; import swatch from '__internal/core/utils/swatch_container'; +import themes from 'ui/themes'; import documentSizeCallbacks from '__internal/core/utils/document_size_callbacks'; import keyboardMock from '../../helpers/keyboardMock.js'; import pointerMock from '../../helpers/pointerMock.js'; @@ -325,6 +326,134 @@ testModule('render', moduleConfig, () => { assert.ok(overlayContainer.parent().hasClass(VIEWPORT_CLASS), 'overlay\'s container is the viewport\'s child'); }); + /* + * The generic bundle this suite loads scopes no modes, so these declare --dx-theme-mode + * themselves. It goes on the scope element rather than into a document stylesheet: under + * ?shadowDom the fixture lives in a shadow root, which a rule in cannot reach, while a + * declaration on the element inherits down either way - and inheriting is the whole mechanism. + */ + const declareMode = ($scope, mode) => { + $scope.get(0).style.setProperty('--dx-theme-mode', mode); + + return $scope; + }; + + const modeScope = (mode) => declareMode($('
').appendTo('#container'), mode); + + test('Theme mode - an open overlay moves to the mode its owner resolves to after refreshMode', function(assert) { + const $scope = modeScope('light'); + const overlay = $('
').appendTo($scope).dxOverlay({ visible: true }).dxOverlay('instance'); + + assert.ok(overlay.$wrapper().parent().hasClass('dx-theme-mode-light'), 'starts in the mode of its owner'); + + declareMode($scope, 'dark'); + themes.refreshMode(); + + const container = overlay.$wrapper().parent(); + + assert.ok(container.hasClass('dx-theme-mode-dark'), 'moved to the new mode'); + assert.ok(container.parent().hasClass(VIEWPORT_CLASS), 'still a child of the viewport'); + + overlay.dispose(); + $scope.remove(); + }); + + test('Theme mode - refreshMode leaves a hidden overlay alone until it is shown', function(assert) { + const $scope = modeScope('light'); + const overlay = $('
').appendTo($scope).dxOverlay({ visible: false }).dxOverlay('instance'); + + declareMode($scope, 'dark'); + themes.refreshMode(); + + assert.strictEqual(overlay.$wrapper().parent().length, 0, 'a hidden overlay is not attached anywhere'); + + overlay.show(); + + assert.ok(overlay.$wrapper().parent().hasClass('dx-theme-mode-dark'), 'and picks the current mode when shown'); + + overlay.dispose(); + $scope.remove(); + }); + + test('Theme mode - refreshMode leaves an overlay whose scope did not change where it is', function(assert) { + const $scope = modeScope('light'); + const overlay = $('
').appendTo($scope).dxOverlay({ visible: true }).dxOverlay('instance'); + const $wrapper = overlay.$wrapper(); + const container = $wrapper.parent().get(0); + + /* + * Re-appending a child that is already in place is not a no-op: the node is detached and + * re-inserted, which takes the focus out of the overlay, restarts its animations and + * reloads any iframe in its content. Watching for the detach is steadier than asserting on + * document.activeElement, which needs the window to be focused. + */ + const containerWatch = new MutationObserver(() => {}); + const wrapperWatch = new MutationObserver(() => {}); + + containerWatch.observe(container, { childList: true }); + wrapperWatch.observe($wrapper.get(0), { childList: true }); + + themes.refreshMode(); + + const moves = containerWatch.takeRecords().length + wrapperWatch.takeRecords().length; + + containerWatch.disconnect(); + wrapperWatch.disconnect(); + + assert.strictEqual(overlay.$wrapper().parent().get(0), container, 'still in the same container'); + assert.strictEqual(moves, 0, 'and nothing was detached to put it back where it already was'); + + overlay.dispose(); + $scope.remove(); + }); + + test('Theme mode - a move keeps the focus inside the overlay', function(assert) { + const $scope = modeScope('light'); + const overlay = $('
').appendTo($scope).dxOverlay({ + visible: true, + contentTemplate: () => $('') + }).dxOverlay('instance'); + + const input = overlay.$content().find('.probe-input').get(0); + // under ?shadowDom the overlay lives in a shadow root, and the document reports its host + const focused = () => input.getRootNode().activeElement; + + input.focus(); + input.setSelectionRange(4, 4); + + assert.strictEqual(focused(), input, 'the caret starts inside the overlay'); + + declareMode($scope, 'dark'); + themes.refreshMode(); + + assert.ok(overlay.$wrapper().parent().hasClass('dx-theme-mode-dark'), 'the overlay did move'); + assert.strictEqual(focused(), input, 'and the focus came back to where it was'); + assert.strictEqual(input.selectionStart, 4, 'with the caret still in place'); + + overlay.dispose(); + $scope.remove(); + }); + + test('Theme mode - a disposed overlay stops listening', function(assert) { + const $scope = modeScope('light'); + const overlay = $('
').appendTo($scope).dxOverlay({ visible: true }).dxOverlay('instance'); + let told = 0; + + overlay._themeModeChangeHandler = () => { told += 1; }; + + themes.refreshMode(); + + assert.strictEqual(told, 1, 'a live overlay is subscribed - without this the case below passes for the wrong reason'); + + overlay.dispose(); + declareMode($scope, 'dark'); + + themes.refreshMode(); + + assert.strictEqual(told, 1, 'and a disposed one is not told again'); + $scope.remove(); + }); + test('Overlay does not fail if swatch is undefined (render before documentReady, T713615, T1143527)', function(assert) { const stub = sinon.stub(swatch, 'getSwatchContainer').callsFake(() => { return undefined; diff --git a/packages/devextreme/ts/dx.all.d.ts b/packages/devextreme/ts/dx.all.d.ts index 72666c8a5343..f11b2354ec43 100644 --- a/packages/devextreme/ts/dx.all.d.ts +++ b/packages/devextreme/ts/dx.all.d.ts @@ -34103,6 +34103,14 @@ declare module DevExpress.ui { * [descr:ui.themes.initialized(callback)] */ static initialized(callback: Function): void; + /** + * [descr:ui.themes.mode(element)] + */ + static mode(element: DevExpress.core.UserDefinedElement): 'light' | 'dark'; + /** + * [descr:ui.themes.refreshMode()] + */ + static refreshMode(): void; } /** * [descr:Widget]