diff --git a/.github/workflows/pr-storybook-deploy-manual.yml b/.github/workflows/pr-storybook-deploy-manual.yml index 78a0e73bdb25..1e8b4232112b 100644 --- a/.github/workflows/pr-storybook-deploy-manual.yml +++ b/.github/workflows/pr-storybook-deploy-manual.yml @@ -85,6 +85,17 @@ jobs: run: | pnpx nx build devextreme-react-storybook + - name: Build playground (static) + if: inputs.action == 'deploy' + run: | + cd packages/devextreme + pnpm run build:playground + + - name: Copy playground into Storybook output + if: inputs.action == 'deploy' + run: | + cp -r packages/devextreme/dist/playground apps/react-storybook/storybook-static/playground + - name: Deploy/remove PR preview uses: rossjrw/pr-preview-action@ffa7509e91a3ec8dfc2e5536c4d5c1acdf7a6de9 # v1.8.1 with: diff --git a/packages/devextreme/build/vite-plugin-demo-html.ts b/packages/devextreme/build/vite-plugin-demo-html.ts new file mode 100644 index 000000000000..fa05ad743368 --- /dev/null +++ b/packages/devextreme/build/vite-plugin-demo-html.ts @@ -0,0 +1,255 @@ +import fs from 'fs'; +import path from 'path'; +import type { PluginOption, ViteDevServer } from 'vite'; + +const demosRoot = path.resolve(__dirname, '../../../apps/demos/Demos'); +const demosImagesRoot = path.resolve(__dirname, '../../../apps/demos/images'); +const demosDataRoot = path.resolve(__dirname, '../../../apps/demos/data'); +const demosSharedRoot = path.resolve(__dirname, '../../../apps/demos/shared'); +const mustacheRoot = path.resolve(__dirname, '../../../apps/demos/node_modules/mustache'); +const vectormapDataRoot = path.resolve(__dirname, '../artifacts/js/vectormap-data'); +const menuMetaPath = path.resolve(__dirname, '../../../apps/demos/menuMeta.json'); + +type DemoEntry = { title: string; name: string; files: string[] }; +type DemosMap = Record; + +const DEMO_FILE_EXTENSIONS = ['.html', '.js', '.css', '.json']; + +function getDemoFiles(jqueryDir: string): string[] { + if (!fs.existsSync(jqueryDir)) return []; + return fs.readdirSync(jqueryDir) + .filter((f) => DEMO_FILE_EXTENSIONS.includes(path.extname(f))) + .sort(); +} + +function buildDemosMap(): DemosMap { + const result: DemosMap = {}; + const menuMeta: unknown[] = JSON.parse(fs.readFileSync(menuMetaPath, 'utf-8')); + + function traverse(groups: unknown[]): void { + for (const group of groups as Array<{ Groups?: unknown[]; Demos?: Array<{ Title: string; Name: string; Widget?: string }> }>) { + if (group.Demos) { + for (const demo of group.Demos) { + if (!demo.Widget || !demo.Name) continue; + const jqueryDir = path.join(demosRoot, demo.Widget, demo.Name, 'jQuery'); + if (!fs.existsSync(path.join(jqueryDir, 'index.html'))) continue; + if (!result[demo.Widget]) result[demo.Widget] = []; + result[demo.Widget].push({ title: demo.Title, name: demo.Name, files: getDemoFiles(jqueryDir) }); + } + } + if (group.Groups) traverse(group.Groups); + } + } + + traverse(menuMeta); + return result; +} + +function transformDemoHtml(html: string): string { + const relativeScripts: string[] = []; + + const scriptRe = /<\/script>/gi; + let m: RegExpExecArray | null; + while ((m = scriptRe.exec(html)) !== null) { + if (!m[1].includes('node_modules')) { + relativeScripts.push(m[1]); + } + } + + const loaderScript = ``; + + return html + .replace(/]+node_modules[^>]*><\/script>/gi, '') + .replace(/]+devextreme-dist[^>]*>/gi, '') + .replace(/]*><\/script>/gi, '') + .replace(/<\/body>/, `${loaderScript}\n`) + .replace(//, `\n `); +} + +function transformDemoHtmlForBuild(html: string, existingFiles?: Set): string { + const relativeScripts: string[] = []; + + const scriptRe = /<\/script>/gi; + let m: RegExpExecArray | null; + while ((m = scriptRe.exec(html)) !== null) { + const src = m[1]; + if (src.includes('node_modules')) { + const vmMatch = src.match(/\/vectormap-data\/([^"]+)/); + if (vmMatch) { + relativeScripts.push(`../../../vectormap-data/${vmMatch[1]}`); + continue; + } + const mustacheMatch = src.match(/\/mustache\/(mustache(?:\.min)?\.js)/); + if (mustacheMatch) { + relativeScripts.push(`../../../mustache/${mustacheMatch[1]}`); + continue; + } + } else if (!existingFiles || existingFiles.has(path.basename(src))) { + relativeScripts.push(src); + } + } + + const indexIdx = relativeScripts.indexOf('index.js'); + if (indexIdx !== -1) { + relativeScripts.splice(indexIdx, 1); + relativeScripts.push('index.js'); + } + + const loaderScript = ``; + + return html + .replace(/]+node_modules[^>]*><\/script>/gi, '') + .replace(/]+(devextreme-dist|node_modules)[^>]*>/gi, '') + .replace(/]*><\/script>/gi, '') + .replace(/<\/body>/, `${loaderScript}\n`) + .replaceAll('../../../../', '../../../'); +} + +function serveFile(res: import('http').ServerResponse, filePath: string): boolean { + if (!fs.existsSync(filePath)) return false; + const ext = path.extname(filePath); + const contentTypes: Record = { + '.html': 'text/html; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + }; + const content = fs.readFileSync(filePath); + res.setHeader('Content-Type', contentTypes[ext] ?? 'application/octet-stream'); + res.end(ext === '.html' ? transformDemoHtml(content.toString('utf-8')) : content); + return true; +} + +export default function demoHtmlPlugin(): PluginOption { + const demosMap = buildDemosMap(); + const VIRTUAL_ID = 'virtual:demos-meta'; + const RESOLVED_ID = `\0${VIRTUAL_ID}`; + let isBuild = false; + + return { + name: 'devextreme-demo-html', + + configResolved(config: { command: string }) { + isBuild = config.command === 'build'; + }, + + resolveId(id: string) { + if (id === VIRTUAL_ID) return RESOLVED_ID; + return null; + }, + + load(id: string) { + if (id === RESOLVED_ID) { + const root = isBuild ? '' : demosRoot; + return `export default ${JSON.stringify({ demosRoot: root, demos: demosMap })}`; + } + return null; + }, + + writeBundle(options: { dir?: string }) { + const outDir = options.dir ?? 'dist'; + const demosOut = path.join(outDir, 'demos'); + + if (fs.existsSync(demosImagesRoot)) { + fs.cpSync(demosImagesRoot, path.join(outDir, 'images'), { recursive: true }); + } + if (fs.existsSync(demosDataRoot)) { + fs.cpSync(demosDataRoot, path.join(outDir, 'data'), { recursive: true }); + } + if (fs.existsSync(demosSharedRoot)) { + fs.cpSync(demosSharedRoot, path.join(outDir, 'shared'), { recursive: true }); + } + if (fs.existsSync(vectormapDataRoot)) { + fs.cpSync(vectormapDataRoot, path.join(outDir, 'vectormap-data'), { recursive: true }); + } + if (fs.existsSync(mustacheRoot)) { + fs.mkdirSync(path.join(outDir, 'mustache'), { recursive: true }); + const mustacheFile = path.join(mustacheRoot, 'mustache.min.js'); + if (fs.existsSync(mustacheFile)) { + fs.copyFileSync(mustacheFile, path.join(outDir, 'mustache', 'mustache.min.js')); + } + } + + for (const [widget, demos] of Object.entries(demosMap)) { + for (const { name } of demos) { + const jqueryDir = path.join(demosRoot, widget, name, 'jQuery'); + if (!fs.existsSync(jqueryDir)) continue; + + const demoOut = path.join(demosOut, widget, name); + fs.mkdirSync(demoOut, { recursive: true }); + + for (const file of fs.readdirSync(jqueryDir)) { + const src = path.join(jqueryDir, file); + const dest = path.join(demoOut, file); + const ext = path.extname(file); + if (ext === '.html') { + const existingFiles = new Set(fs.readdirSync(jqueryDir)); + fs.writeFileSync(dest, transformDemoHtmlForBuild(fs.readFileSync(src, 'utf-8'), existingFiles)); + } else if (ext === '.js' || ext === '.css') { + fs.writeFileSync(dest, fs.readFileSync(src, 'utf-8').replaceAll('../../../../', '../../../')); + } else { + fs.copyFileSync(src, dest); + } + } + } + } + }, + + configureServer(server: ViteDevServer) { + server.watcher.add(path.join(demosRoot, '**', 'jQuery', '*.{js,css,html}')); + + server.middlewares.use('/images', (req, res, next) => { + const filePath = path.join(demosImagesRoot, decodeURIComponent(req.url ?? '/')); + if (serveFile(res, filePath)) return; + next(); + }); + + server.middlewares.use('/demos', (req, res, next) => { + const urlPath = decodeURIComponent(req.url ?? '/'); + const segments = urlPath.replace(/^\//, '').split('/').filter(Boolean); + + if (segments.length < 2) { next(); return; } + + const [widget, name, ...rest] = segments; + const jqueryDir = path.join(demosRoot, widget, name, 'jQuery'); + + if (rest.length === 0 || urlPath.endsWith('/')) { + if (serveFile(res, path.join(jqueryDir, 'index.html'))) return; + } else { + const file = rest.join('/'); + if (serveFile(res, path.join(jqueryDir, file))) return; + } + + next(); + }); + }, + }; +} diff --git a/packages/devextreme/build/vite-plugin-devextreme-qunit.ts b/packages/devextreme/build/vite-plugin-devextreme-qunit.ts new file mode 100644 index 000000000000..0104259f9a17 --- /dev/null +++ b/packages/devextreme/build/vite-plugin-devextreme-qunit.ts @@ -0,0 +1,178 @@ +/* eslint-disable */ +import { transformAsync } from '@babel/core'; +import type { PluginOption } from 'vite'; + +function removeUninitializedClassFields(): unknown { + return { + visitor: { + ClassProperty(path: { node: { value: unknown }; remove: () => void }) { + if (path.node.value === null || path.node.value === undefined) { + path.remove(); + } + }, + }, + }; +} + +function moveFieldInitializersToConstructor(): unknown { + return { + visitor: { + Class(path: { + node: { + body: { body: unknown[] }; + superClass?: unknown; + }; + }) { + const body = path.node.body.body; + + type CtorParam = { type: string; name?: string; left?: { name?: string } }; + type CtorBodyStmt = { + type: string; + expression?: { + type: string; + callee?: { type: string }; + operator?: string; + left?: { type: string; object?: { type: string }; property?: { name: string } }; + right?: { type: string; name?: string }; + }; + }; + type ClassMember = { + type: string; + kind?: string; + key?: { name: string }; + value?: unknown; + static?: boolean; + params?: CtorParam[]; + body?: { body: CtorBodyStmt[] }; + }; + + const fieldsToMove: ClassMember[] = []; + const remaining: unknown[] = []; + + for (const member of body as ClassMember[]) { + if ( + member.type === 'ClassProperty' + && member.value != null + && !member.static + ) { + fieldsToMove.push(member); + } else { + remaining.push(member); + } + } + + if (fieldsToMove.length === 0) return; + + const ctor = (remaining as ClassMember[]).find( + (m) => m.type === 'ClassMethod' && m.kind === 'constructor', + ); + + if (!ctor) return; + + const assignments = fieldsToMove.map((field) => ({ + type: 'ExpressionStatement', + expression: { + type: 'AssignmentExpression', + operator: '=', + left: { + type: 'MemberExpression', + object: { type: 'ThisExpression' }, + property: { type: 'Identifier', name: field.key!.name }, + computed: false, + }, + right: field.value, + }, + })); + + const ctorBody = ctor.body!.body; + + const paramNames = new Set( + (ctor.params ?? []).map((p) => p.name ?? p.left?.name).filter(Boolean), + ); + + const superCallIdx = ctorBody.findIndex( + (stmt) => stmt.type === 'ExpressionStatement' + && stmt.expression?.type === 'CallExpression' + && stmt.expression?.callee?.type === 'Super', + ); + + let insertIdx = superCallIdx !== -1 ? superCallIdx + 1 : 0; + + while (insertIdx < ctorBody.length) { + const stmt = ctorBody[insertIdx]; + if ( + stmt.type === 'ExpressionStatement' + && stmt.expression?.type === 'AssignmentExpression' + && stmt.expression.operator === '=' + && stmt.expression.left?.type === 'MemberExpression' + && stmt.expression.left.object?.type === 'ThisExpression' + && stmt.expression.right?.type === 'Identifier' + && paramNames.has(stmt.expression.right.name) + ) { + insertIdx += 1; + } else { + break; + } + } + + ctorBody.splice(insertIdx, 0, ...assignments); + + path.node.body.body = remaining; + }, + }, + }; +} + +export default function devextremeQunitBabelPlugin(): PluginOption { + return { + name: 'devextreme-qunit-babel', + enforce: 'pre', + + async transform(code: string, id: string) { + if (!/\.[jt]sx?$/.test(id) || id.includes('node_modules')) { + return null; + } + + if (id.includes('/testing/helpers/') && /define\s*\(\s*function\s*\(\s*require/.test(code)) { + return null; + } + + const isTSX = id.endsWith('.tsx'); + const isTS = id.endsWith('.ts') || isTSX; + + const plugins: unknown[] = []; + + if (isTS) { + plugins.push([ + '@babel/plugin-transform-typescript', + { + isTSX, + allExtensions: true, + allowDeclareFields: true, + optimizeConstEnums: true, + }, + ]); + } + + plugins.push( + removeUninitializedClassFields, + moveFieldInitializersToConstructor, + ['@babel/plugin-proposal-decorators', { legacy: true }], + 'babel-plugin-inferno', + ); + + + const result = await transformAsync(code, { + filename: id, + plugins, + sourceMaps: true, + }); + + if (!result?.code) { + return null; + } + + return { code: result.code, map: result.map }; + }, + }; +} diff --git a/packages/devextreme/build/vite-plugin-devextreme.ts b/packages/devextreme/build/vite-plugin-devextreme.ts new file mode 100644 index 000000000000..eb7d9d2e87f1 --- /dev/null +++ b/packages/devextreme/build/vite-plugin-devextreme.ts @@ -0,0 +1,194 @@ +import { transformAsync } from '@babel/core'; +import type { PluginOption } from 'vite'; + +function removeCjsExportsAssignments(): unknown { + return { + visitor: { + ExpressionStatement(path: { + node: { expression: { type: string; operator?: string; left?: { type: string; object?: { type: string; name?: string } } } }; + remove: () => void; + }) { + const { expression } = path.node; + if ( + expression.type === 'AssignmentExpression' + && expression.operator === '=' + && expression.left?.type === 'MemberExpression' + && expression.left.object?.type === 'Identifier' + && expression.left.object.name === 'exports' + ) { + path.remove(); + } + }, + }, + }; +} +function removeUninitializedClassFields(): unknown { + return { + visitor: { + ClassProperty(path: { node: { value: unknown }; remove: () => void }) { + if (path.node.value === null || path.node.value === undefined) { + path.remove(); + } + }, + }, + }; +} + +function moveFieldInitializersToConstructor(): unknown { + return { + visitor: { + Class(path: { + node: { + body: { body: unknown[] }; + superClass?: unknown; + }; + }) { + const body = path.node.body.body; + + type CtorParam = { type: string; name?: string; left?: { name?: string } }; + type CtorBodyStmt = { + type: string; + expression?: { + type: string; + callee?: { type: string }; + operator?: string; + left?: { type: string; object?: { type: string }; property?: { name: string } }; + right?: { type: string; name?: string }; + }; + }; + type ClassMember = { + type: string; + kind?: string; + key?: { name: string }; + value?: unknown; + static?: boolean; + params?: CtorParam[]; + body?: { body: CtorBodyStmt[] }; + }; + + const fieldsToMove: ClassMember[] = []; + const remaining: unknown[] = []; + + for (const member of body as ClassMember[]) { + if ( + member.type === 'ClassProperty' + && member.value != null + && !member.static + ) { + fieldsToMove.push(member); + } else { + remaining.push(member); + } + } + + if (fieldsToMove.length === 0) return; + + const ctor = (remaining as ClassMember[]).find( + (m) => m.type === 'ClassMethod' && m.kind === 'constructor', + ); + + if (!ctor) return; + + const assignments = fieldsToMove.map((field) => ({ + type: 'ExpressionStatement', + expression: { + type: 'AssignmentExpression', + operator: '=', + left: { + type: 'MemberExpression', + object: { type: 'ThisExpression' }, + property: { type: 'Identifier', name: field.key!.name }, + computed: false, + }, + right: field.value, + }, + })); + + const ctorBody = ctor.body!.body; + + const paramNames = new Set( + (ctor.params ?? []).map((p) => p.name ?? p.left?.name).filter(Boolean), + ); + + const superCallIdx = ctorBody.findIndex( + (stmt) => stmt.type === 'ExpressionStatement' + && stmt.expression?.type === 'CallExpression' + && stmt.expression?.callee?.type === 'Super', + ); + + let insertIdx = superCallIdx !== -1 ? superCallIdx + 1 : 0; + + while (insertIdx < ctorBody.length) { + const stmt = ctorBody[insertIdx]; + if ( + stmt.type === 'ExpressionStatement' + && stmt.expression?.type === 'AssignmentExpression' + && stmt.expression.operator === '=' + && stmt.expression.left?.type === 'MemberExpression' + && stmt.expression.left.object?.type === 'ThisExpression' + && stmt.expression.right?.type === 'Identifier' + && paramNames.has(stmt.expression.right.name) + ) { + insertIdx += 1; + } else { + break; + } + } + + ctorBody.splice(insertIdx, 0, ...assignments); + + path.node.body.body = remaining; + }, + }, + }; +} + +export default function devextremeInfernoPlugin(): PluginOption { + return { + name: 'devextreme-inferno', + enforce: 'pre', + + async transform(code: string, id: string) { + if (!/\.[jt]sx?$/.test(id) || id.includes('node_modules')) { + return null; + } + + const isTSX = id.endsWith('.tsx'); + const isTS = id.endsWith('.ts') || isTSX; + + const plugins: unknown[] = []; + + if (isTS) { + plugins.push([ + '@babel/plugin-transform-typescript', + { + isTSX, + allExtensions: true, + allowDeclareFields: true, + optimizeConstEnums: true, + }, + ]); + } + + plugins.push( + removeCjsExportsAssignments, + removeUninitializedClassFields, + moveFieldInitializersToConstructor, + ['@babel/plugin-proposal-decorators', { legacy: true }], + 'babel-plugin-inferno', + ); + + const result = await transformAsync(code, { + filename: id, + plugins, + sourceMaps: true, + }); + + if (!result?.code) { + return null; + } + + return { code: result.code, map: result.map }; + }, + }; +} diff --git a/packages/devextreme/build/vite-plugin-qunit.ts b/packages/devextreme/build/vite-plugin-qunit.ts new file mode 100644 index 000000000000..e58a8eac0336 --- /dev/null +++ b/packages/devextreme/build/vite-plugin-qunit.ts @@ -0,0 +1,481 @@ +/* eslint-disable */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { PluginOption, ViteDevServer } from 'vite'; +import type { IncomingMessage, ServerResponse } from 'node:http'; + +const TESTING_ROOT = path.resolve(__dirname, '../testing'); +const TESTS_DIR = path.join(TESTING_ROOT, 'tests'); +const JS_DIR = path.resolve(__dirname, '../js'); +const ARTIFACTS_CSS_DIR = path.resolve(__dirname, '../artifacts/css'); + +interface CategoryMeta { + constellation?: string; + explicit?: boolean; + runOnDevices?: boolean; +} + +interface TestSuite { + category: string; + file: string; + constellation: string; +} + +function discoverTests(): TestSuite[] { + const suites: TestSuite[] = []; + const categories = fs.readdirSync(TESTS_DIR).filter((d) => { + const fullPath = path.join(TESTS_DIR, d); + return fs.statSync(fullPath).isDirectory() && d.startsWith('DevExpress.'); + }); + + for (const category of categories) { + const catDir = path.join(TESTS_DIR, category); + const metaPath = path.join(catDir, '__meta.json'); + let constellation = 'misc'; + + if (fs.existsSync(metaPath)) { + const meta: CategoryMeta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); + if (meta.constellation) { + constellation = String(meta.constellation); + } + } + + const files = fs.readdirSync(catDir).filter((f) => f.endsWith('.tests.js')); + + for (const file of files) { + suites.push({ category, file, constellation }); + } + + const subDirs = fs.readdirSync(catDir).filter((d) => { + const subPath = path.join(catDir, d); + return fs.statSync(subPath).isDirectory() && !d.startsWith('__'); + }); + + for (const subDir of subDirs) { + const subFiles = fs.readdirSync(path.join(catDir, subDir)).filter((f) => f.endsWith('.tests.js')); + for (const file of subFiles) { + suites.push({ category, file: `${subDir}/${file}`, constellation }); + } + } + } + + return suites; +} + +const CSS_THEME_MAP: Record = { + 'generic_light.css': 'dx.light.css', + 'material_blue_light.css': 'dx.material.blue.light.css', + 'fluent_blue_light.css': 'dx.fluent.blue.light.css', + 'gantt.css': 'dx-gantt.css', +}; + +function generateTestIndexHtml(suites: TestSuite[]): string { + const byCategory: Record = {}; + for (const s of suites) { + (byCategory[s.category] ??= []).push(s); + } + + const links = Object.keys(byCategory).sort().map((cat) => { + const items = byCategory[cat].map((s) => + `
  • ${s.file}
  • ` + ).join('\n'); + return `

    ${cat} (${byCategory[cat].length})

      ${items}
    `; + }).join('\n'); + + return ` + + + QUnit Tests (Vite) + + + +

    QUnit Tests (Vite)

    +

    Total: ${suites.length} test files

    + ${links} + +`; +} + +function generateTestPageHtml(category: string, testFile: string): string { + const testPath = `/testing/tests/${category}/${testFile}`; + + return ` + + + + QUnit: ${category}/${testFile} + + + + + + + + + +
    +
    + + +`; +} + +function transformAmdHelper(code: string, id: string): string | null { + const amdPattern = /\(function\s*\(\s*root\s*,\s*factory\s*\)\s*\{[\s\S]*?define\s*\(\s*function\s*\(\s*require\s*,\s*exports\s*,\s*module\s*\)\s*\{([\s\S]*?)\}\s*\)\s*;[\s\S]*?\}\s*\(\s*(?:window|this)\s*,\s*function\s*\(([\s\S]*?)\)\s*\{([\s\S]*)\}\s*\)\s*\)\s*;?\s*$/s; + + const match = code.match(amdPattern); + if (!match) return null; + + const amdBody = match[1].trim(); + const factoryParams = match[2].trim(); + const factoryBody = match[3]; + + const factoryRequires: Array<{ modulePath: string; property?: string }> = []; + const factoryCallMatch = amdBody.match(/factory\s*\(([\s\S]*)\)/); + if (factoryCallMatch) { + const factoryArgs = factoryCallMatch[1]; + const argReqPattern = /require\(\s*'([^']+)'\s*\)(?:\.(\w+))?/g; + let argMatch; + while ((argMatch = argReqPattern.exec(factoryArgs)) !== null) { + factoryRequires.push({ modulePath: argMatch[1], property: argMatch[2] }); + } + } + + const standaloneRequires: Array<{ varName: string; modulePath: string; property?: string }> = []; + const standalonePattern = /(\w+)\s*=\s*require\(\s*'([^']+)'\s*\)(?:\.(\w+))?\s*;/g; + let stMatch; + while ((stMatch = standalonePattern.exec(amdBody)) !== null) { + const isInFactory = factoryCallMatch && amdBody.indexOf(stMatch[0]) > amdBody.indexOf('factory('); + if (!isInFactory) { + standaloneRequires.push({ varName: stMatch[1], modulePath: stMatch[2], property: stMatch[3] }); + } + } + + const paramList = factoryParams.split(',').map((p) => p.trim()).filter(Boolean); + + const standaloneVarNames = new Set(standaloneRequires.map((sr) => sr.varName)); + const preambleLines: string[] = []; + const codeLines = code.split('\n'); + for (const line of codeLines) { + const varMatch = line.match(/^\s*(?:let|var|const)\s+(\w+)/); + if (varMatch && !line.match(/function\s*\(\s*root/)) { + if (!standaloneVarNames.has(varMatch[1])) { + preambleLines.push(line); + } + } else { + break; + } + } + const preamble = preambleLines.join('\n'); + + const imports: string[] = []; + const callArgs: string[] = []; + + for (const sr of standaloneRequires) { + if (sr.property) { + imports.push(`import { ${sr.property} as ${sr.varName} } from '${sr.modulePath}';`); + } else { + imports.push(`import ${sr.varName} from '${sr.modulePath}';`); + } + } + + for (let i = 0; i < paramList.length; i++) { + const param = paramList[i]; + const req = factoryRequires[i]; + + if (req) { + const alias = `__amd_${i}`; + if (req.property) { + imports.push(`import { ${req.property} as ${alias} } from '${req.modulePath}';`); + } else { + imports.push(`import ${alias} from '${req.modulePath}';`); + } + callArgs.push(alias); + } else if (param === '$' || param === 'jQuery') { + imports.push(`import $ from 'jquery';`); + callArgs.push('$'); + } else if (param === 'inferno') { + imports.push(`import inferno from 'inferno';`); + callArgs.push('inferno'); + } else { + callArgs.push('undefined'); + } + } + + const result = `${preamble ? preamble + '\n' : ''}${imports.join('\n')} + +const __factory = (function(${factoryParams}) {${factoryBody}}); +const __result = __factory(${callArgs.join(', ')}); +export default __result; +`; + + return result; +} + +function transformCjsToEsm(code: string): string | null { + if (!/\brequire\s*\(/.test(code)) return null; + + const imports: string[] = []; + let importIdx = 0; + let modified = code; + + modified = modified.replace( + /^(const|let|var)\s+\{\s*([^}]+)\s*\}\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)\s*;?/gm, + (match, decl, names, source) => { + const cleanNames = names.split(',').map((n: string) => n.trim()).filter(Boolean).join(', '); + imports.push(`import { ${cleanNames} } from '${source}';`); + return ''; + }, + ); + + modified = modified.replace( + /^(const|let|var)\s+([$\w]+)\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)\.([$\w]+)\s*;?/gm, + (match, decl, varName, source, prop) => { + imports.push(`import { ${prop} as ${varName} } from '${source}';`); + return ''; + }, + ); + + modified = modified.replace( + /^(const|let|var)\s+([$\w]+)\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)\s*;?/gm, + (match, decl, varName, source) => { + imports.push(`import ${varName} from '${source}';`); + return ''; + }, + ); + + modified = modified.replace( + /^require\s*\(\s*['"]([^'"]+)['"]\s*\)\s*;?/gm, + (match, source) => { + imports.push(`import '${source}';`); + return ''; + }, + ); + + if (imports.length === 0) return null; + + return imports.join('\n') + '\n' + modified; +} + +function postTransformPlugin(): PluginOption { + const defaultCache = new Map(); + + function sourceHasDefault(filePath: string): boolean { + if (defaultCache.has(filePath)) return defaultCache.get(filePath)!; + try { + const src = fs.readFileSync(filePath, 'utf8'); + const has = /\bexport\s+default\b/.test(src) || + /\bexport\s*\{[^}]*\bdefault\b/.test(src); + defaultCache.set(filePath, has); + return has; + } catch { + defaultCache.set(filePath, true); + return true; + } + } + + return { + name: 'devextreme-post-transform', + enforce: 'post', + + transform(code: string, id: string) { + if (id.includes('node_modules')) return undefined; + if (!id.includes('/js/')) return undefined; + if (id.includes('/testing/')) return undefined; + + let modified = code; + let changed = false; + + const namespaceDefaultPattern = /import\s*\*\s*as\s+(\w+)\s+from\s*['"]([^'"]+)['"]\s*;\s*export\s+default\s+\1\s*;/g; + modified = modified.replace(namespaceDefaultPattern, (match, varName, source) => { + changed = true; + return `import * as ${varName} from '${source}'; export default { ...${varName} };`; + }); + + if (changed) { + return { code: modified, map: null }; + } + + if (sourceHasDefault(id)) return undefined; + if (!/\bexport\s/.test(code)) return undefined; + + const localNames: string[] = []; + const directPattern = /\bexport\s+(?:const|let|var|function|class)\s+(\w+)/g; + let m; + while ((m = directPattern.exec(code)) !== null) { + localNames.push(m[1]); + } + + const localExportEntries: Array<{ local: string; exported: string }> = []; + const localExportPattern = /\bexport\s*\{\s*([^}]+?)\s*\}(?!\s*from)/g; + while ((m = localExportPattern.exec(code)) !== null) { + const entries = m[1].split(',').map((n: string) => { + const parts = n.trim().split(/\s+as\s+/); + return { local: parts[0].trim(), exported: (parts[1] || parts[0]).trim() }; + }).filter((e: { local: string; exported: string }) => e.local && e.exported); + localExportEntries.push(...entries); + } + + const allLocal = [ + ...localNames.map((n) => ({ local: n, exported: n })), + ...localExportEntries, + ]; + + if (allLocal.length > 0) { + const props = allLocal.map((e) => + e.local === e.exported ? e.local : `${e.exported}: ${e.local}` + ).join(', '); + return { + code: code + `\nexport default { ${props} };\n`, + map: null, + }; + } + + const reExportImports: string[] = []; + const reExportKeys: string[] = []; + const reExportPattern = /\bexport\s*\{\s*([^}]+?)\s*\}\s*from\s*["']([^"']+)["']/g; + let idx = 0; + while ((m = reExportPattern.exec(code)) !== null) { + const names = m[1].split(',').map((n: string) => { + const parts = n.trim().split(/\s+as\s+/); + return { original: parts[0].trim(), exported: (parts[1] || parts[0]).trim() }; + }); + const source = m[2]; + for (const { original, exported } of names) { + const alias = `_sd${idx++}`; + reExportImports.push(`import { ${original} as ${alias} } from ${JSON.stringify(source)};`); + reExportKeys.push(`${exported}: ${alias}`); + } + } + + if (reExportKeys.length > 0) { + return { + code: code + `\n${reExportImports.join('\n')}\nexport default { ${reExportKeys.join(', ')} };\n`, + map: null, + }; + } + + const starExportPattern = /\bexport\s*\*\s*from\s*["']([^"']+)["']/g; + const starSources: string[] = []; + while ((m = starExportPattern.exec(code)) !== null) { + starSources.push(m[1]); + } + if (starSources.length > 0) { + const starImports = starSources.map((s, i) => + `import * as __star${i} from ${JSON.stringify(s)};` + ).join('\n'); + const spread = starSources.map((_, i) => `...(__star${i})`).join(', '); + return { + code: code + `\n${starImports}\nexport default { ${spread} };\n`, + map: null, + }; + } + + return undefined; + }, + }; +} + +export default function qunitPlugin(): PluginOption[] { + let suites: TestSuite[] = []; + + const mainPlugin: PluginOption = { + name: 'devextreme-qunit', + enforce: 'pre', + + configureServer(server: ViteDevServer) { + suites = discoverTests(); + + server.middlewares.use((req: IncomingMessage, res: ServerResponse, next: () => void) => { + const url = req.url?.split('?')[0] ?? ''; + + if (url === '/qunit' || url === '/qunit/') { + res.setHeader('Content-Type', 'text/html'); + res.end(generateTestIndexHtml(suites)); + return; + } + + const testMatch = url.match(/^\/qunit\/([^/]+)\/(.+\.tests\.js)$/); + if (testMatch) { + const [, category, testFile] = testMatch; + const testPath = path.join(TESTS_DIR, category, testFile); + if (fs.existsSync(testPath)) { + res.setHeader('Content-Type', 'text/html'); + res.end(generateTestPageHtml(category, testFile)); + return; + } + } + + next(); + }); + }, + + resolveId(source: string) { + if (source.endsWith('!')) { + const clean = source.slice(0, -1); + + const cssFile = CSS_THEME_MAP[clean]; + if (cssFile) { + return path.join(ARTIFACTS_CSS_DIR, cssFile); + } + + if (clean.endsWith('.json')) { + const jsonPath = path.resolve(JS_DIR, clean); + if (fs.existsSync(jsonPath)) { + return jsonPath; + } + } + } + + return undefined; + }, + + transform(code: string, id: string) { + if (id.includes('/testing/helpers/') && id.endsWith('.js')) { + if (/define\s*\(\s*function\s*\(\s*require/.test(code)) { + const transformed = transformAmdHelper(code, id); + if (transformed) { + return { code: transformed, map: null }; + } + } + } + + if (id.includes('/testing/') && id.endsWith('.js') && code.includes('require(')) { + const transformed = transformCjsToEsm(code); + if (transformed) { + return { code: transformed, map: null }; + } + } + + return undefined; + }, + }; + + return [mainPlugin, postTransformPlugin()]; +} + +export { discoverTests, type TestSuite }; diff --git a/packages/devextreme/eslint.config.mjs b/packages/devextreme/eslint.config.mjs index b971ed40dfaa..e148c46f5d32 100644 --- a/packages/devextreme/eslint.config.mjs +++ b/packages/devextreme/eslint.config.mjs @@ -41,6 +41,7 @@ export default [ 'themebuilder-scss/src/data/metadata/*', 'js/bundles/dx.custom.js', 'testing/jest/utils/transformers/*', + 'vite.config.ts', '**/ts/', 'js/common/core/localization/cldr-data/*', 'js/common/core/localization/default_messages.js', diff --git a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/resource_manager.ts b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/resource_manager.ts index 98a66f5c01f7..5dc2eea7c483 100644 --- a/packages/devextreme/js/__internal/scheduler/utils/resource_manager/resource_manager.ts +++ b/packages/devextreme/js/__internal/scheduler/utils/resource_manager/resource_manager.ts @@ -15,17 +15,23 @@ import { groupResources } from './group_utils'; import type { GroupLeaf, GroupNode } from './types'; export class ResourceManager { - public resources: ResourceLoader[] = []; + public resources: ResourceLoader[]; - public resourceById: Record = {}; + public resourceById: Record; - public groups: string[] = []; + public groups: string[]; - public groupsLeafs: GroupLeaf[] = []; + public groupsLeafs: GroupLeaf[]; - public groupsTree: GroupNode[] = []; + public groupsTree: GroupNode[]; constructor(config: ResourceConfig[]) { + this.resources = []; + this.resourceById = {}; + this.groups = []; + this.groupsLeafs = []; + this.groupsTree = []; + config?.filter(getResourceIndex) .forEach((item) => { const loader = new ResourceLoader(item); diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/m_virtual_scrolling.ts b/packages/devextreme/js/__internal/scheduler/workspaces/m_virtual_scrolling.ts index 55523a568f96..75d95cbf125f 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/m_virtual_scrolling.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/m_virtual_scrolling.ts @@ -338,17 +338,22 @@ export class VirtualScrollingDispatcher { } class VirtualScrollingBase { - _state = this.defaultState; + _state: any; - viewportSize = this.options.viewportSize; + viewportSize: number; - _itemSize = this.options.itemSize; + _itemSize: number; - _position = -1; + _position: number; - _itemSizeChanged = false; + _itemSizeChanged: boolean; constructor(public options: any) { + this._state = this.defaultState; + this.viewportSize = options.viewportSize; + this._itemSize = options.itemSize; + this._position = -1; + this._itemSizeChanged = false; this.updateState(0); } diff --git a/packages/devextreme/js/__internal/ui/hierarchical_collection/data_adapter.ts b/packages/devextreme/js/__internal/ui/hierarchical_collection/data_adapter.ts index e73e8e921c34..97b1dff5524e 100644 --- a/packages/devextreme/js/__internal/ui/hierarchical_collection/data_adapter.ts +++ b/packages/devextreme/js/__internal/ui/hierarchical_collection/data_adapter.ts @@ -60,33 +60,39 @@ export interface DataAdapterOptions { SearchBoxController.setEditorClass(TextBox); class DataAdapter { - options: DataAdapterOptions = { - dataAccessors: {} as DataAccessors, - items: [], - multipleSelection: true, - recursiveSelection: false, - recursiveExpansion: false, - rootValue: 0, - searchValue: '', - dataType: 'tree', - searchMode: 'contains', - dataConverter: new HierarchicalDataConverter(), - onNodeChanged: noop, - sort: null, - disabledNodeSelectionMode: 'recursiveAndAll', - }; + options: DataAdapterOptions; - _disabledNodesKeys: ItemKey[] = []; + _disabledNodesKeys: ItemKey[]; - _selectedNodesKeys: ItemKey[] = []; + _selectedNodesKeys: ItemKey[]; - _expandedNodesKeys: ItemKey[] = []; + _expandedNodesKeys: ItemKey[]; - _dataStructure: (InternalNode | null)[] = []; + _dataStructure: (InternalNode | null)[]; - _initialDataStructure: (InternalNode | null)[] = []; + _initialDataStructure: (InternalNode | null)[]; constructor(options: DataAdapterOptions) { + this.options = { + dataAccessors: {} as DataAccessors, + items: [], + multipleSelection: true, + recursiveSelection: false, + recursiveExpansion: false, + rootValue: 0, + searchValue: '', + dataType: 'tree', + searchMode: 'contains', + dataConverter: new HierarchicalDataConverter(), + onNodeChanged: noop, + sort: null, + disabledNodeSelectionMode: 'recursiveAndAll', + }; + this._disabledNodesKeys = []; + this._selectedNodesKeys = []; + this._expandedNodesKeys = []; + this._dataStructure = []; + this._initialDataStructure = []; extend(this.options, options); this.options.dataConverter.setDataAccessors(this.options.dataAccessors); diff --git a/packages/devextreme/package.json b/packages/devextreme/package.json index 4a3b326af28e..5189c82dc057 100644 --- a/packages/devextreme/package.json +++ b/packages/devextreme/package.json @@ -69,9 +69,13 @@ "@babel/core": "7.29.0", "@babel/eslint-parser": "catalog:", "@babel/parser": "7.29.0", + "@babel/plugin-proposal-decorators": "7.23.9", + "@babel/plugin-transform-class-properties": "7.23.3", "@babel/plugin-transform-modules-commonjs": "7.28.6", "@babel/plugin-transform-runtime": "7.29.0", + "@babel/plugin-transform-typescript": "7.23.6", "@babel/preset-env": "7.29.0", + "@babel/preset-typescript": "7.23.3", "@devextreme-generator/angular": "3.0.12", "@devextreme-generator/build-helpers": "3.0.12", "@devextreme-generator/core": "3.0.12", @@ -211,6 +215,8 @@ "uuid": "9.0.1", "vinyl": "2.2.1", "vinyl-named": "1.1.0", + "@playwright/test": "^1.50.0", + "vite": "^7.1.3", "webpack": "5.105.4", "webpack-stream": "7.0.0", "yaml": "2.5.0", @@ -246,6 +252,11 @@ "validate-ts": "gulp validate-ts", "validate-declarations": "dx-tools validate-declarations --sources ./js --exclude \"js/(renovation|__internal|.eslintrc.js)\" --compiler-options \"{ \\\"typeRoots\\\": [] }\"", "testcafe-in-docker": "docker build -f ./testing/testcafe/docker/Dockerfile -t testcafe-testing . && docker run -it testcafe-testing", + "dev:playground": "vite", + "build:playground": "vite build", + "test:playground": "playwright test --config playground/playwright.config.ts", + "test:qunit-vite": "tsx testing/vite-qunit-runner.ts", + "dev:qunit": "vite --config vite-qunit.config.ts", "test-jest": "cross-env NODE_OPTIONS='--expose-gc' jest --no-coverage --runInBand --selectProjects jsdom-tests", "test-jest:watch": "jest --watch", "test-jest:node": "jest --no-coverage --runInBand --selectProjects node-tests", diff --git a/packages/devextreme/playground/catalog.ts b/packages/devextreme/playground/catalog.ts new file mode 100644 index 000000000000..6d59cf638142 --- /dev/null +++ b/packages/devextreme/playground/catalog.ts @@ -0,0 +1,412 @@ +import '../js/integration/jquery'; +import { setLicenseCheckSkipCondition } from '../js/__internal/core/license/license_validation'; +import $ from 'jquery'; +import { registry } from './widgets/registry'; +import type { WidgetInit } from './widgets/registry'; +import { setupThemeSelector } from './newThemeSelector'; +import type { WidgetId } from './widget-ids'; +import demosMeta from 'virtual:demos-meta'; + +const { demosRoot, demos: demosMap } = demosMeta; + +setLicenseCheckSkipCondition(); + +const RECENTS_KEY = 'dx-playground-recents'; +const PINNED_KEY = 'dx-playground-pinned'; +const PINNED_DEMOS_KEY = 'dx-playground-pinned-demos'; +const RECENT_DEMOS_KEY = 'dx-playground-recent-demos'; +const MAX_RECENTS = 5; +const MAX_RECENT_DEMOS = 20; + +interface PinnedDemo { widget: string; name: string; title: string } +interface RecentDemo { widget: string; name: string } + +const widgetGroups: { label: string; ids: WidgetId[] }[] = [ + { + label: 'Grids', + ids: ['dataGrid', 'cardView', 'treeList', 'filterBuilder', 'sortable', 'draggable'], + }, + { + label: 'Scheduler', + ids: ['scheduler', 'pivotGrid', 'pivotGridFieldChooser', 'pagination', 'gantt', 'recurrenceEditor'], + }, + { + label: 'Editors', + ids: [ + 'autocomplete', 'calendar', 'chat', 'checkBox', 'colorBox', 'dateBox', 'dateRangeBox', + 'dropDownBox', 'dropDownButton', 'fileUploader', 'htmlEditor', 'loadPanel', 'lookup', + 'map', 'numberBox', 'popover', 'popup', 'progressBar', 'radioGroup', 'rangeSlider', + 'selectBox', 'slider', 'switch', 'tagBox', 'textArea', 'textBox', 'toast', 'tooltip', + 'validationGroup', 'validationSummary', 'validator', + ], + }, + { + label: 'Navigation', + ids: [ + 'accordion', 'actionSheet', 'box', 'button', 'buttonGroup', 'contextMenu', 'diagram', + 'drawer', 'fileManager', 'form', 'gallery', 'list', 'loadIndicator', 'menu', 'multiView', + 'resizable', 'responsiveBox', 'scrollView', 'speedDialAction', 'splitter', 'stepper', + 'tabPanel', 'tabs', 'tileView', 'toolbar', 'treeView', + 'barGauge', 'bullet', 'chart', 'circularGauge', 'funnel', 'linearGauge', 'pieChart', + 'polarChart', 'rangeSelector', 'sankey', 'sparkline', 'treeMap', 'vectorMap', + ], + }, +]; + +function getPinned(): WidgetId[] { + try { + return JSON.parse(localStorage.getItem(PINNED_KEY) ?? '[]') as WidgetId[]; + } catch { + return []; + } +} + +function isPinned(id: WidgetId): boolean { + return getPinned().includes(id); +} + +function togglePin(id: WidgetId): void { + const pinned = getPinned(); + const idx = pinned.indexOf(id); + if (idx === -1) { + pinned.push(id); + } else { + pinned.splice(idx, 1); + } + localStorage.setItem(PINNED_KEY, JSON.stringify(pinned)); + renderPinned(); + buildNav($('#search').val() as string); +} + +function getPinnedDemos(): PinnedDemo[] { + try { + return JSON.parse(localStorage.getItem(PINNED_DEMOS_KEY) ?? '[]') as PinnedDemo[]; + } catch { + return []; + } +} + +function isDemoPinned(widget: string, name: string): boolean { + return getPinnedDemos().some((d) => d.widget === widget && d.name === name); +} + +function toggleDemoPin(widget: string, name: string, title: string): void { + const demos = getPinnedDemos(); + const idx = demos.findIndex((d) => d.widget === widget && d.name === name); + if (idx === -1) { + demos.push({ widget, name, title }); + } else { + demos.splice(idx, 1); + } + localStorage.setItem(PINNED_DEMOS_KEY, JSON.stringify(demos)); + renderPinned(); +} + +function renderPinned(): void { + const $section = $('#pinned-section'); + const $list = $('#pinned-list'); + const pinned = getPinned().filter((id) => registry[id]); + const pinnedDemos = getPinnedDemos(); + + $list.empty(); + + if (pinned.length === 0 && pinnedDemos.length === 0) { + $section.hide(); + return; + } + + $section.show(); + + const currentHash = location.hash.slice(1); + + pinned.forEach((id) => { + const entry = registry[id]; + const $li = $('
  • ').appendTo($list); + $('').attr('href', `#${id}`).text(entry.label).toggleClass('active', id === currentHash).appendTo($li); + $('') + .on('click', (e) => { e.preventDefault(); togglePin(id); }) + .appendTo($li); + }); + + pinnedDemos.forEach(({ widget, name, title }) => { + const hash = `demo/${widget}/${name}`; + const $li = $('
  • ').appendTo($list); + $('').attr('href', `#${hash}`).toggleClass('active', currentHash === hash) + .html(`${widget}${title}`) + .appendTo($li); + $('') + .on('click', (e) => { e.preventDefault(); toggleDemoPin(widget, name, title); }) + .appendTo($li); + }); +} + +function getRecentDemos(): RecentDemo[] { + try { + return JSON.parse(localStorage.getItem(RECENT_DEMOS_KEY) ?? '[]') as RecentDemo[]; + } catch { + return []; + } +} + +function pushRecentDemo(widget: string, name: string): void { + const recents = getRecentDemos().filter((d) => !(d.widget === widget && d.name === name)); + recents.unshift({ widget, name }); + localStorage.setItem(RECENT_DEMOS_KEY, JSON.stringify(recents.slice(0, MAX_RECENT_DEMOS))); +} + +function getDemoChipColors(widget: string, name: string): { borderColor: string; color: string; background: string } { + const idx = getRecentDemos().findIndex((d) => d.widget === widget && d.name === name); + if (idx === -1) { + return { borderColor: 'hsl(217, 8%, 78%)', color: 'hsl(217, 8%, 58%)', background: '#fff' }; + } + const t = idx / Math.max(MAX_RECENT_DEMOS - 1, 1); + const s = Math.round(78 - t * 58); + const borderL = Math.round(54 + t * 18); + const textL = Math.round(34 + t * 18); + const bgL = Math.round(95 + t * 3); + return { + borderColor: `hsl(217, ${s}%, ${borderL}%)`, + color: `hsl(217, ${s}%, ${textL}%)`, + background: `hsl(217, ${s}%, ${bgL}%)`, + }; +} + +function getRecents(): WidgetId[] { + try { + return JSON.parse(localStorage.getItem(RECENTS_KEY) ?? '[]') as WidgetId[]; + } catch { + return []; + } +} + +function pushRecent(id: WidgetId): void { + const recents = getRecents().filter((r) => r !== id); + recents.unshift(id); + localStorage.setItem(RECENTS_KEY, JSON.stringify(recents.slice(0, MAX_RECENTS))); + renderRecents(); +} + +function deleteRecent(id: WidgetId): void { + const recents = getRecents().filter((r) => r !== id); + localStorage.setItem(RECENTS_KEY, JSON.stringify(recents)); + renderRecents(); +} + +function renderRecents(): void { + const $section = $('#recents-section'); + const $list = $('#recents-list'); + const recents = getRecents().filter((id) => registry[id]); + + $list.empty(); + + if (recents.length === 0) { + $section.hide(); + return; + } + + $section.show(); + + const currentId = location.hash.slice(1); + + recents.forEach((id) => { + const entry = registry[id]; + const $li = $('
  • ').appendTo($list); + $('').attr('href', `#${id}`).text(entry.label).toggleClass('active', id === currentId).appendTo($li); + $('') + .on('click', (e) => { + e.preventDefault(); + deleteRecent(id); + }) + .appendTo($li); + }); +} + +function buildNav(filter: string): void { + const $nav = $('#groups-nav'); + $nav.empty(); + const lc = filter.toLowerCase(); + const currentHash = location.hash.slice(1); + + widgetGroups.forEach((group) => { + const matching = group.ids.filter((id) => { + if (!registry[id]) return false; + if (!lc) return true; + const widgetMatches = registry[id].label.toLowerCase().includes(lc) || id.toLowerCase().includes(lc); + const demoMatches = (demosMap[getWidgetName(id as WidgetId)] ?? []).some( + (d) => d.title.toLowerCase().includes(lc) || d.name.toLowerCase().includes(lc), + ); + return widgetMatches || demoMatches; + }); + + if (matching.length === 0) return; + + const $details = $('
    ').attr('open', '').appendTo($nav); + $('').text(group.label).appendTo($details); + const $ul = $('