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.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/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..4cca05c5feaa 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,9 @@ "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-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 = $('