Compare commits
25 Commits
Author | SHA1 | Date | |
---|---|---|---|
0410f6c196 | |||
24aa7588c5 | |||
b46fe8fe93 | |||
b47c2053b5 | |||
16bf8001ae | |||
792e77f824 | |||
9b39196195 | |||
ad59e3d334 | |||
0de4283fae | |||
6f9c92a866 | |||
0ec2f2aebb | |||
cd22106597 | |||
a212536cfa | |||
18297d54c4 | |||
f790ca38d0 | |||
ce2b42ecd5 | |||
09e299bc2e | |||
bbc7dfe29a | |||
49b9e833e8 | |||
f739bb608e | |||
286a6f9088 | |||
e32b9589a5 | |||
6427510c98 | |||
cf92a423cf | |||
3f3677ebaa |
1
.serena/.gitignore
vendored
Normal file
1
.serena/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/cache
|
6
.serena/memories/done_checklist.md
Normal file
6
.serena/memories/done_checklist.md
Normal file
@@ -0,0 +1,6 @@
|
||||
Before finishing a task:
|
||||
- Run `pnpm run build` to ensure TypeScript compile + bundling succeed.
|
||||
- Verify `dist_ts_web/` and `dist_bundle/bundle.js` updated.
|
||||
- Optionally run `pnpm run test` and inspect failures.
|
||||
- Avoid changing public APIs unless required; keep changes scoped.
|
||||
- Update readme or inline docs only if user-facing behavior changes.
|
11
.serena/memories/project_overview.md
Normal file
11
.serena/memories/project_overview.md
Normal file
@@ -0,0 +1,11 @@
|
||||
Project: @design.estate/dees-catalog
|
||||
Purpose: A component library of dynamic Web Components (TypeScript) for building modern web apps.
|
||||
Tech stack: TypeScript (ES2022, NodeNext), decorators, custom elements via @design.estate/dees-element (Lit-style), bundling with esbuild via @git.zone/tsbundle, TypeScript building via @git.zone/tsbuild (tsfolders), tests with @git.zone/tstest, various UI libs (tiptap, apexcharts, monaco-editor runtime via CDN), DOM helpers via @design.estate/dees-domtools.
|
||||
Structure:
|
||||
- ts_web/: source of web components and pages
|
||||
- dist_ts_web/: transpiled TS output
|
||||
- dist_bundle/: production bundle (bundle.js + map)
|
||||
- test/: tests
|
||||
- html/: static demo assets
|
||||
Key configs: tsconfig.json sets ES2022, NodeNext module/resolution, decorators enabled, skipLibCheck enabled to avoid third-party d.ts issues.
|
||||
Entrypoints: ts_web/index.ts for bundling; custom elements annotated with @customElement.
|
5
.serena/memories/style_and_conventions.md
Normal file
5
.serena/memories/style_and_conventions.md
Normal file
@@ -0,0 +1,5 @@
|
||||
Language: TypeScript, ES2022 target, NodeNext module + resolution.
|
||||
Patterns: Web Components with @customElement decorators; class-based components extending DeesElement; styles via css/cssManager; template render via html tagged literal.
|
||||
Typing: Prefer explicit types where practical; tolerate `any` for external browser-injected libs (e.g., monaco) to keep build healthy.
|
||||
Config: skipLibCheck enabled to avoid third-party d.ts breakages; exclude built declaration outputs.
|
||||
Formatting/Linting: Not explicitly configured; follow existing style (2-space indents, single quotes often, semicolons present).
|
6
.serena/memories/suggested_commands.md
Normal file
6
.serena/memories/suggested_commands.md
Normal file
@@ -0,0 +1,6 @@
|
||||
Build: pnpm run build
|
||||
Watch: pnpm run watch
|
||||
Test: pnpm run test
|
||||
Docs: pnpm run buildDocs
|
||||
Inspect bundle size: ls -lh dist_bundle/bundle.js
|
||||
Open demo (if applicable): serve static `html/` with any web server
|
67
.serena/project.yml
Normal file
67
.serena/project.yml
Normal file
@@ -0,0 +1,67 @@
|
||||
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
|
||||
# * For C, use cpp
|
||||
# * For JavaScript, use typescript
|
||||
# Special requirements:
|
||||
# * csharp: Requires the presence of a .sln file in the project folder.
|
||||
language: typescript
|
||||
|
||||
# whether to use the project's gitignore file to ignore files
|
||||
# Added on 2025-04-07
|
||||
ignore_all_files_in_gitignore: true
|
||||
# list of additional paths to ignore
|
||||
# same syntax as gitignore, so you can use * and **
|
||||
# Was previously called `ignored_dirs`, please update your config if you are using that.
|
||||
# Added (renamed) on 2025-04-07
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project by name.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_lines`: Deletes a range of lines within a file.
|
||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||
# Should only be used in settings where the system prompt cannot be set,
|
||||
# e.g. in clients you have no control over, like Claude Desktop.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||
# * `remove_project`: Removes a project from the Serena configuration.
|
||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||
# * `switch_modes`: Activates modes by providing a list of their names
|
||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
excluded_tools: []
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
project_name: "dees-catalog"
|
38
changelog.md
38
changelog.md
@@ -1,5 +1,43 @@
|
||||
# Changelog
|
||||
|
||||
## 2025-09-18 - 1.12.0 - feat(dees-stepper)
|
||||
Revamp dees-stepper: modern styling, new steps and improved navigation/validation
|
||||
|
||||
- Visual refresh for dees-stepper: updated card shapes, shadows, refined borders and stronger selected-state visuals for a modern shadcn-inspired look
|
||||
- Improved transitions and animations (transform, box-shadow, filter) for smoother step selection and show/hide behavior
|
||||
- Expanded default/demo steps: replaced small sample with a richer multi-step flow (Account Setup, Profile Details, Contact Information, Team Size, Goals, Brand Preferences, Integrations, Review & Launch)
|
||||
- Enhanced step interactions: safer goNext/goBack handling with boundary checks and reset of validation flags to avoid stale validation state
|
||||
- Better toolbar/controls placement for stepper demo (spacing, counters, accessible back control) and improved keyboard/UX affordances
|
||||
- Minor documentation and meta updates: readme.plan.md extended with dees-stepper plan items and added .claude/settings.local.json
|
||||
|
||||
## 2025-09-18 - 1.11.8 - fix(ci)
|
||||
Add local tool permissions config to allow running pnpm scripts and enable mcp__zen__chat
|
||||
|
||||
- Add local settings file to grant permission to run pnpm scripts (Bash(pnpm run:*))
|
||||
- Enable mcp__zen__chat permission in local tool settings
|
||||
|
||||
## 2025-09-16 - 1.11.7 - fix(readme)
|
||||
Expand README with comprehensive component documentation, examples and developer guide; add local Claude settings
|
||||
|
||||
- Expanded README substantially: installation, component overview, detailed component docs, usage examples, demos and developer guidance
|
||||
- Updated many example snippets and API usage examples (icons, inputs, editor, forms, overlays, charts, etc.) to be more explicit and consistent
|
||||
- Added .claude/settings.local.json to configure local Claude permissions for repository tooling
|
||||
- No runtime or library code changes — documentation and demo content only
|
||||
|
||||
## 2025-09-16 - 1.11.6 - fix(dees-table)
|
||||
Improve Lucene range comparisons, pin monaco-editor to 0.52.2, and add local dev metadata
|
||||
|
||||
- Fix lucene inRange behavior to correctly compare homogeneous types (strings, numbers, dates) and fall back to string comparison when needed (ts_web/elements/dees-table/lucene.ts).
|
||||
- Pin monaco-editor to 0.52.2 in package.json to avoid a breaking upgrade regression observed with ^0.53.0.
|
||||
- Add local development/tooling metadata and conveniences: .claude/settings.local.json (tool permissions) and .serena/ memory files (done_checklist, project_overview, style_and_conventions, suggested_commands).
|
||||
- Minor housekeeping: update project dev docs / memories to capture build/test/checklist guidance.
|
||||
|
||||
## 2025-09-16 - 1.11.5 - fix(ci)
|
||||
Add local Claude agent settings for CI tooling
|
||||
|
||||
- Add .claude/settings.local.json to configure local Claude agent permissions
|
||||
- Allow Bash commands matching pnpm run:* and the mcp__zen__chat permission for development tooling
|
||||
|
||||
## 2025-09-10 - 1.11.4 - fix(readme)
|
||||
Rewrite and expand README with Quick Start, feature highlights, demos and usage examples; add local Claude settings file
|
||||
|
||||
|
15
package.json
15
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@design.estate/dees-catalog",
|
||||
"version": "1.11.4",
|
||||
"version": "1.12.0",
|
||||
"private": false,
|
||||
"description": "A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.",
|
||||
"main": "dist_ts_web/index.js",
|
||||
@@ -10,7 +10,8 @@
|
||||
"test": "tstest test/ --web --verbose --timeout 30 --logfile",
|
||||
"build": "tsbuild tsfolders --allowimplicitany && tsbundle element --production --bundler esbuild",
|
||||
"watch": "tswatch element",
|
||||
"buildDocs": "tsdoc"
|
||||
"buildDocs": "tsdoc",
|
||||
"postinstall": "node scripts/update-monaco-version.cjs"
|
||||
},
|
||||
"author": "Lossless GmbH",
|
||||
"license": "MIT",
|
||||
@@ -24,7 +25,7 @@
|
||||
"@fortawesome/free-solid-svg-icons": "^7.0.1",
|
||||
"@push.rocks/smarti18n": "^1.0.4",
|
||||
"@push.rocks/smartpromise": "^4.2.0",
|
||||
"@push.rocks/smartstring": "^4.0.15",
|
||||
"@push.rocks/smartstring": "^4.1.0",
|
||||
"@tiptap/core": "^2.23.0",
|
||||
"@tiptap/extension-link": "^2.23.0",
|
||||
"@tiptap/extension-text-align": "^2.23.0",
|
||||
@@ -33,11 +34,11 @@
|
||||
"@tiptap/starter-kit": "^2.23.0",
|
||||
"@tsclass/tsclass": "^9.2.0",
|
||||
"@webcontainer/api": "1.2.0",
|
||||
"apexcharts": "^5.3.4",
|
||||
"apexcharts": "^5.3.5",
|
||||
"highlight.js": "11.11.1",
|
||||
"ibantools": "^4.5.1",
|
||||
"lucide": "^0.542.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"lucide": "^0.544.0",
|
||||
"monaco-editor": "0.52.2",
|
||||
"pdfjs-dist": "^4.10.38",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0"
|
||||
@@ -45,7 +46,7 @@
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^2.6.8",
|
||||
"@git.zone/tsbundle": "^2.5.1",
|
||||
"@git.zone/tstest": "^2.3.6",
|
||||
"@git.zone/tstest": "^2.3.8",
|
||||
"@git.zone/tswatch": "^2.2.1",
|
||||
"@push.rocks/projectinfo": "^5.0.2",
|
||||
"@push.rocks/tapbundle": "^6.0.3",
|
||||
|
568
pnpm-lock.yaml
generated
568
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
onlyBuiltDependencies:
|
||||
- puppeteer
|
BIN
readme.plan.md
BIN
readme.plan.md
Binary file not shown.
44
scripts/update-monaco-version.cjs
Executable file
44
scripts/update-monaco-version.cjs
Executable file
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..');
|
||||
|
||||
function resolveMonacoPackageJson() {
|
||||
try {
|
||||
const resolvedPath = require.resolve('monaco-editor/package.json', {
|
||||
paths: [projectRoot],
|
||||
});
|
||||
return resolvedPath;
|
||||
} catch (error) {
|
||||
console.error('[dees-editor] Unable to resolve monaco-editor/package.json');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getMonacoVersion() {
|
||||
const monacoPackagePath = resolveMonacoPackageJson();
|
||||
const monacoPackage = require(monacoPackagePath);
|
||||
if (!monacoPackage.version) {
|
||||
throw new Error('[dees-editor] monaco-editor/package.json does not expose a version field');
|
||||
}
|
||||
return monacoPackage.version;
|
||||
}
|
||||
|
||||
function writeVersionModule(version) {
|
||||
const targetDir = path.join(projectRoot, 'ts_web', 'elements', 'dees-editor');
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
const targetFile = path.join(targetDir, 'version.ts');
|
||||
const fileContent = `// Auto-generated by scripts/update-monaco-version.cjs\nexport const MONACO_VERSION = '${version}';\n`;
|
||||
fs.writeFileSync(targetFile, fileContent, 'utf8');
|
||||
console.log(`[dees-editor] Wrote ${path.relative(projectRoot, targetFile)} with monaco-editor@${version}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const version = getMonacoVersion();
|
||||
writeVersionModule(version);
|
||||
} catch (error) {
|
||||
console.error('[dees-editor] Failed to update Monaco version module.');
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
}
|
28
test/test.dashboardgrid-layout.node.ts
Normal file
28
test/test.dashboardgrid-layout.node.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { tap, expect } from '@push.rocks/tapbundle';
|
||||
|
||||
import {
|
||||
resolveWidgetPlacement,
|
||||
collectCollisions,
|
||||
} from '../ts_web/elements/dees-dashboardgrid/layout.ts';
|
||||
import type { DashboardWidget } from '../ts_web/elements/dees-dashboardgrid/types.ts';
|
||||
|
||||
tap.test('dashboardgrid does not overlap widgets after swap attempt', async () => {
|
||||
const widgets: DashboardWidget[] = [
|
||||
{ id: 'w0', x: 6, y: 5, w: 1, h: 3 },
|
||||
{ id: 'w1', x: 6, y: 1, w: 1, h: 3 },
|
||||
{ id: 'w2', x: 3, y: 0, w: 2, h: 2 },
|
||||
{ id: 'w3', x: 9, y: 0, w: 1, h: 2 },
|
||||
{ id: 'w4', x: 4, y: 3, w: 1, h: 2 },
|
||||
];
|
||||
|
||||
const placement = resolveWidgetPlacement(widgets, 'w0', { x: 6, y: 3 }, 12);
|
||||
expect(placement).toBeTruthy();
|
||||
|
||||
const layout = placement!.widgets;
|
||||
for (const widget of layout) {
|
||||
const collisions = collectCollisions(layout, widget, widget.x, widget.y, widget.w, widget.h);
|
||||
expect(collisions).toBeEmptyArray();
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@design.estate/dees-catalog',
|
||||
version: '1.11.4',
|
||||
version: '1.12.0',
|
||||
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
|
||||
}
|
||||
|
@@ -1,191 +0,0 @@
|
||||
import { html, css, cssManager } from '@design.estate/dees-element';
|
||||
import type { DeesDashboardgrid } from './dees-dashboardgrid.js';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
|
||||
export const demoFunc = () => {
|
||||
return html`
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
const grid = elementArg.querySelector('#dashboardGrid') as DeesDashboardgrid;
|
||||
|
||||
// Set initial widgets
|
||||
grid.widgets = [
|
||||
{
|
||||
id: 'metrics1',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 3,
|
||||
h: 2,
|
||||
title: 'Revenue',
|
||||
icon: 'lucide:dollarSign',
|
||||
content: html`
|
||||
<div style="padding: 20px;">
|
||||
<div style="font-size: 32px; font-weight: 700; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">$124,563</div>
|
||||
<div style="color: #22c55e; font-size: 14px; margin-top: 8px;">↑ 12.5% from last month</div>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
{
|
||||
id: 'metrics2',
|
||||
x: 3,
|
||||
y: 0,
|
||||
w: 3,
|
||||
h: 2,
|
||||
title: 'Users',
|
||||
icon: 'lucide:users',
|
||||
content: html`
|
||||
<div style="padding: 20px;">
|
||||
<div style="font-size: 32px; font-weight: 700; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">8,234</div>
|
||||
<div style="color: #3b82f6; font-size: 14px; margin-top: 8px;">↑ 5.2% from last week</div>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
{
|
||||
id: 'chart1',
|
||||
x: 6,
|
||||
y: 0,
|
||||
w: 6,
|
||||
h: 4,
|
||||
title: 'Analytics',
|
||||
icon: 'lucide:lineChart',
|
||||
content: html`
|
||||
<div style="padding: 20px; height: 100%; display: flex; align-items: center; justify-content: center;">
|
||||
<div style="text-align: center; color: #71717a;">
|
||||
<dees-icon .icon=${'lucide:lineChart'} style="font-size: 48px; margin-bottom: 16px;"></dees-icon>
|
||||
<div>Chart visualization area</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
];
|
||||
|
||||
// Configure grid
|
||||
grid.cellHeight = 80;
|
||||
grid.margin = { top: 10, right: 10, bottom: 10, left: 10 };
|
||||
grid.enableAnimation = true;
|
||||
grid.showGridLines = false;
|
||||
|
||||
let widgetCounter = 4;
|
||||
|
||||
// Control buttons
|
||||
const buttons = elementArg.querySelectorAll('dees-button');
|
||||
buttons.forEach(button => {
|
||||
const text = button.textContent?.trim();
|
||||
|
||||
if (text === 'Toggle Animation') {
|
||||
button.addEventListener('click', () => {
|
||||
grid.enableAnimation = !grid.enableAnimation;
|
||||
});
|
||||
} else if (text === 'Toggle Grid Lines') {
|
||||
button.addEventListener('click', () => {
|
||||
grid.showGridLines = !grid.showGridLines;
|
||||
});
|
||||
} else if (text === 'Add Widget') {
|
||||
button.addEventListener('click', () => {
|
||||
const newWidget = {
|
||||
id: `widget${widgetCounter++}`,
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 3,
|
||||
h: 2,
|
||||
autoPosition: true,
|
||||
title: `Widget ${widgetCounter - 1}`,
|
||||
icon: 'lucide:package',
|
||||
content: html`
|
||||
<div style="padding: 20px; text-align: center;">
|
||||
<div style="color: #71717a;">New widget content</div>
|
||||
<div style="margin-top: 8px; font-size: 24px; font-weight: 600; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">${Math.floor(Math.random() * 1000)}</div>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
grid.addWidget(newWidget, true);
|
||||
});
|
||||
} else if (text === 'Compact Grid') {
|
||||
button.addEventListener('click', () => {
|
||||
grid.compact();
|
||||
});
|
||||
} else if (text === 'Toggle Edit Mode') {
|
||||
button.addEventListener('click', () => {
|
||||
grid.editable = !grid.editable;
|
||||
button.textContent = grid.editable ? 'Lock Grid' : 'Unlock Grid';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Listen to grid events
|
||||
grid.addEventListener('widget-move', (e: CustomEvent) => {
|
||||
console.log('Widget moved:', e.detail.widget);
|
||||
});
|
||||
|
||||
grid.addEventListener('widget-resize', (e: CustomEvent) => {
|
||||
console.log('Widget resized:', e.detail.widget);
|
||||
});
|
||||
}}>
|
||||
<style>
|
||||
${css`
|
||||
.demoBox {
|
||||
position: relative;
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#09090b')};
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 40px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.demo-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.demo-controls dees-button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.grid-container-wrapper {
|
||||
flex: 1;
|
||||
min-height: 600px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.info {
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
font-size: 12px;
|
||||
font-family: 'Geist Sans', sans-serif;
|
||||
text-align: center;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div class="demoBox">
|
||||
<div class="demo-controls">
|
||||
<dees-button-group label="Animation:">
|
||||
<dees-button>Toggle Animation</dees-button>
|
||||
</dees-button-group>
|
||||
|
||||
<dees-button-group label="Display:">
|
||||
<dees-button>Toggle Grid Lines</dees-button>
|
||||
</dees-button-group>
|
||||
|
||||
<dees-button-group label="Actions:">
|
||||
<dees-button>Add Widget</dees-button>
|
||||
<dees-button>Compact Grid</dees-button>
|
||||
</dees-button-group>
|
||||
|
||||
<dees-button-group label="Mode:">
|
||||
<dees-button>Toggle Edit Mode</dees-button>
|
||||
</dees-button-group>
|
||||
</div>
|
||||
|
||||
<div class="grid-container-wrapper">
|
||||
<dees-dashboardgrid id="dashboardGrid"></dees-dashboardgrid>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
Drag widgets to reposition • Resize from edges and corners • Add widgets with auto-positioning
|
||||
</div>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
};
|
@@ -1,813 +0,0 @@
|
||||
import * as plugins from './00plugins.js';
|
||||
import {
|
||||
DeesElement,
|
||||
type TemplateResult,
|
||||
property,
|
||||
customElement,
|
||||
html,
|
||||
css,
|
||||
cssManager,
|
||||
state,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
import './dees-icon.js';
|
||||
import { demoFunc } from './dees-dashboardgrid.demo.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-dashboardgrid': DeesDashboardgrid;
|
||||
}
|
||||
}
|
||||
|
||||
export interface IDashboardWidget {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
minW?: number;
|
||||
minH?: number;
|
||||
maxW?: number;
|
||||
maxH?: number;
|
||||
content: TemplateResult | string;
|
||||
title?: string;
|
||||
icon?: string;
|
||||
noMove?: boolean;
|
||||
noResize?: boolean;
|
||||
locked?: boolean;
|
||||
autoPosition?: boolean; // Auto-position widget in first available space
|
||||
}
|
||||
|
||||
@customElement('dees-dashboardgrid')
|
||||
export class DeesDashboardgrid extends DeesElement {
|
||||
// STATIC
|
||||
public static demo = demoFunc;
|
||||
|
||||
// INSTANCE
|
||||
@property({ type: Array })
|
||||
public widgets: IDashboardWidget[] = [];
|
||||
|
||||
@property({ type: Number })
|
||||
public cellHeight: number = 80;
|
||||
|
||||
@property({ type: Object })
|
||||
public margin: number | { top?: number; right?: number; bottom?: number; left?: number } = 10;
|
||||
|
||||
@property({ type: Number })
|
||||
public columns: number = 12;
|
||||
|
||||
@property({ type: Boolean })
|
||||
public editable: boolean = true;
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public enableAnimation: boolean = true;
|
||||
|
||||
@property({ type: String })
|
||||
public cellHeightUnit: 'px' | 'em' | 'rem' | 'auto' = 'px';
|
||||
|
||||
@property({ type: Boolean })
|
||||
public rtl: boolean = false; // Right-to-left support
|
||||
|
||||
@property({ type: Boolean })
|
||||
public showGridLines: boolean = false;
|
||||
|
||||
@state()
|
||||
private draggedWidget: IDashboardWidget | null = null;
|
||||
|
||||
@state()
|
||||
private draggedElement: HTMLElement | null = null;
|
||||
|
||||
@state()
|
||||
private dragOffsetX: number = 0;
|
||||
|
||||
@state()
|
||||
private dragOffsetY: number = 0;
|
||||
|
||||
@state()
|
||||
private dragMouseX: number = 0;
|
||||
|
||||
@state()
|
||||
private dragMouseY: number = 0;
|
||||
|
||||
@state()
|
||||
private placeholderPosition: { x: number; y: number } | null = null;
|
||||
|
||||
@state()
|
||||
private resizingWidget: IDashboardWidget | null = null;
|
||||
|
||||
@state()
|
||||
private resizeStartW: number = 0;
|
||||
|
||||
@state()
|
||||
private resizeStartH: number = 0;
|
||||
|
||||
@state()
|
||||
private resizeStartX: number = 0;
|
||||
|
||||
@state()
|
||||
private resizeStartY: number = 0;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 400px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.grid-widget {
|
||||
position: absolute;
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
:host([enableanimation]) .grid-widget {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.grid-widget.dragging {
|
||||
z-index: 1000;
|
||||
transition: none !important;
|
||||
opacity: 0.8;
|
||||
cursor: grabbing;
|
||||
pointer-events: none;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.grid-widget.placeholder {
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.grid-widget.placeholder .widget-content {
|
||||
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.1)')};
|
||||
border: 2px dashed ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.grid-widget.resizing {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.widget-content {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
border-radius: 8px;
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
'0 1px 3px rgba(0, 0, 0, 0.3)'
|
||||
)};
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.grid-widget:hover .widget-content {
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
'0 4px 12px rgba(0, 0, 0, 0.4)'
|
||||
)};
|
||||
}
|
||||
|
||||
.grid-widget.dragging .widget-content {
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'0 16px 48px rgba(0, 0, 0, 0.25)',
|
||||
'0 16px 48px rgba(0, 0, 0, 0.6)'
|
||||
)};
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.widget-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')};
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.widget-header:hover {
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||
}
|
||||
|
||||
.widget-header:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.widget-header.locked {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.widget-header.locked:hover {
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')};
|
||||
}
|
||||
|
||||
.widget-header dees-icon {
|
||||
font-size: 16px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
}
|
||||
|
||||
.widget-body {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: auto;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
.widget-body.has-header {
|
||||
top: 45px;
|
||||
}
|
||||
|
||||
/* Resize handles */
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
background: transparent;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.resize-handle:hover {
|
||||
background: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.resize-handle-e {
|
||||
cursor: ew-resize;
|
||||
width: 12px;
|
||||
right: -6px;
|
||||
top: 10%;
|
||||
height: 80%;
|
||||
}
|
||||
|
||||
.resize-handle-s {
|
||||
cursor: ns-resize;
|
||||
height: 12px;
|
||||
width: 80%;
|
||||
bottom: -6px;
|
||||
left: 10%;
|
||||
}
|
||||
|
||||
.resize-handle-se {
|
||||
cursor: se-resize;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.resize-handle-se::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-right: 2px solid ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
border-bottom: 2px solid ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
}
|
||||
|
||||
.grid-widget:hover .resize-handle-se {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.resize-handle-se:hover {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.resize-handle-se:hover::after {
|
||||
border-color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
}
|
||||
|
||||
/* Placeholder */
|
||||
.grid-placeholder {
|
||||
position: absolute;
|
||||
background: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
opacity: 0.1;
|
||||
border-radius: 8px;
|
||||
border: 2px dashed ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
transition: all 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.empty-state dees-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Grid lines */
|
||||
.grid-lines {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.grid-line-vertical {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.grid-line-horizontal {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
opacity: 0.3;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
if (this.widgets.length === 0) {
|
||||
return html`
|
||||
<div class="empty-state">
|
||||
<dees-icon .icon=${'lucide:layoutGrid'}></dees-icon>
|
||||
<div>No widgets configured</div>
|
||||
<div style="font-size: 14px; margin-top: 8px;">Add widgets to populate the dashboard</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const margins = this.getMargins();
|
||||
const maxY = Math.max(...this.widgets.map(w => w.y + w.h), 4);
|
||||
const cellHeightValue = this.getCellHeight();
|
||||
const gridHeight = maxY * cellHeightValue + (maxY + 1) * margins.vertical;
|
||||
|
||||
return html`
|
||||
<div class="grid-container" style="height: ${gridHeight}px;">
|
||||
${this.showGridLines ? this.renderGridLines(gridHeight) : ''}
|
||||
${this.widgets.map(widget => this.renderWidget(widget))}
|
||||
${this.placeholderPosition && this.draggedWidget ? this.renderPlaceholder() : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderGridLines(gridHeight: number): TemplateResult {
|
||||
const margins = this.getMargins();
|
||||
const cellHeightValue = this.getCellHeight();
|
||||
|
||||
// Convert margin to percentage for consistent calculation
|
||||
const containerWidth = this.getBoundingClientRect().width;
|
||||
const marginHorizontalPercent = (margins.horizontal / containerWidth) * 100;
|
||||
|
||||
const cellWidth = (100 - marginHorizontalPercent * (this.columns + 1)) / this.columns;
|
||||
|
||||
const verticalLines = [];
|
||||
const horizontalLines = [];
|
||||
|
||||
// Vertical lines
|
||||
for (let i = 0; i <= this.columns; i++) {
|
||||
const left = i * cellWidth + i * marginHorizontalPercent;
|
||||
verticalLines.push(html`
|
||||
<div class="grid-line-vertical" style="left: ${left}%;"></div>
|
||||
`);
|
||||
}
|
||||
|
||||
// Horizontal lines
|
||||
const numHorizontalLines = Math.ceil(gridHeight / (cellHeightValue + margins.vertical));
|
||||
for (let i = 0; i <= numHorizontalLines; i++) {
|
||||
const top = i * cellHeightValue + i * margins.vertical;
|
||||
horizontalLines.push(html`
|
||||
<div class="grid-line-horizontal" style="top: ${top}px;"></div>
|
||||
`);
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="grid-lines">
|
||||
${verticalLines}
|
||||
${horizontalLines}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderWidget(widget: IDashboardWidget): TemplateResult {
|
||||
const isDragging = this.draggedWidget?.id === widget.id;
|
||||
const isResizing = this.resizingWidget?.id === widget.id;
|
||||
const isLocked = widget.locked || !this.editable;
|
||||
|
||||
const margins = this.getMargins();
|
||||
const cellHeightValue = this.getCellHeight();
|
||||
|
||||
// Convert margin to percentage of container width for consistent calculation
|
||||
const containerWidth = this.getBoundingClientRect().width;
|
||||
const marginHorizontalPercent = (margins.horizontal / containerWidth) * 100;
|
||||
|
||||
const cellWidth = (100 - marginHorizontalPercent * (this.columns + 1)) / this.columns;
|
||||
|
||||
const left = widget.x * cellWidth + (widget.x + 1) * marginHorizontalPercent;
|
||||
const top = widget.y * cellHeightValue + (widget.y + 1) * margins.vertical;
|
||||
const width = widget.w * cellWidth + (widget.w - 1) * marginHorizontalPercent;
|
||||
const height = widget.h * cellHeightValue + (widget.h - 1) * margins.vertical;
|
||||
|
||||
// Apply transform when dragging for smooth movement
|
||||
let transform = '';
|
||||
if (isDragging && this.draggedElement) {
|
||||
const containerRect = this.getBoundingClientRect();
|
||||
const translateX = this.dragMouseX - containerRect.left - this.dragOffsetX - (left / 100 * containerRect.width);
|
||||
const translateY = this.dragMouseY - containerRect.top - this.dragOffsetY - top;
|
||||
transform = `transform: translate(${translateX}px, ${translateY}px);`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="grid-widget ${isDragging ? 'dragging' : ''} ${isResizing ? 'resizing' : ''}"
|
||||
style="
|
||||
${this.rtl ? 'right' : 'left'}: ${left}%;
|
||||
top: ${top}px;
|
||||
width: ${width}%;
|
||||
height: ${height}px;
|
||||
${transform}
|
||||
"
|
||||
data-widget-id="${widget.id}"
|
||||
>
|
||||
<div class="widget-content">
|
||||
${widget.title ? html`
|
||||
<div
|
||||
class="widget-header ${isLocked ? 'locked' : ''}"
|
||||
@mousedown=${!isLocked && !widget.noMove ? (e: MouseEvent) => this.startDrag(e, widget) : null}
|
||||
>
|
||||
${widget.icon ? html`<dees-icon .icon=${widget.icon}></dees-icon>` : ''}
|
||||
${widget.title}
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="widget-body ${widget.title ? 'has-header' : ''}">
|
||||
${widget.content}
|
||||
</div>
|
||||
${!isLocked && !widget.noResize ? html`
|
||||
<div class="resize-handle resize-handle-e" @mousedown=${(e: MouseEvent) => this.startResize(e, widget, 'e')}></div>
|
||||
<div class="resize-handle resize-handle-s" @mousedown=${(e: MouseEvent) => this.startResize(e, widget, 's')}></div>
|
||||
<div class="resize-handle resize-handle-se" @mousedown=${(e: MouseEvent) => this.startResize(e, widget, 'se')}></div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPlaceholder(): TemplateResult {
|
||||
if (!this.placeholderPosition || !this.draggedWidget) return html``;
|
||||
|
||||
const margins = this.getMargins();
|
||||
const cellHeightValue = this.getCellHeight();
|
||||
|
||||
// Convert margin to percentage of container width for consistent calculation
|
||||
const containerWidth = this.getBoundingClientRect().width;
|
||||
const marginHorizontalPercent = (margins.horizontal / containerWidth) * 100;
|
||||
|
||||
const cellWidth = (100 - marginHorizontalPercent * (this.columns + 1)) / this.columns;
|
||||
|
||||
const left = this.placeholderPosition.x * cellWidth + (this.placeholderPosition.x + 1) * marginHorizontalPercent;
|
||||
const top = this.placeholderPosition.y * cellHeightValue + (this.placeholderPosition.y + 1) * margins.vertical;
|
||||
const width = this.draggedWidget.w * cellWidth + (this.draggedWidget.w - 1) * marginHorizontalPercent;
|
||||
const height = this.draggedWidget.h * cellHeightValue + (this.draggedWidget.h - 1) * margins.vertical;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="grid-widget placeholder"
|
||||
style="
|
||||
${this.rtl ? 'right' : 'left'}: ${left}%;
|
||||
top: ${top}px;
|
||||
width: ${width}%;
|
||||
height: ${height}px;
|
||||
"
|
||||
>
|
||||
<div class="widget-content"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private startDrag(e: MouseEvent, widget: IDashboardWidget) {
|
||||
e.preventDefault();
|
||||
this.draggedWidget = widget;
|
||||
this.draggedElement = (e.currentTarget as HTMLElement).closest('.grid-widget') as HTMLElement;
|
||||
|
||||
const rect = this.draggedElement.getBoundingClientRect();
|
||||
|
||||
this.dragOffsetX = e.clientX - rect.left;
|
||||
this.dragOffsetY = e.clientY - rect.top;
|
||||
|
||||
// Initialize mouse position
|
||||
this.dragMouseX = e.clientX;
|
||||
this.dragMouseY = e.clientY;
|
||||
|
||||
// Initialize placeholder at current widget position
|
||||
this.placeholderPosition = { x: widget.x, y: widget.y };
|
||||
|
||||
document.addEventListener('mousemove', this.handleDrag);
|
||||
document.addEventListener('mouseup', this.endDrag);
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private handleDrag = (e: MouseEvent) => {
|
||||
if (!this.draggedWidget || !this.draggedElement) return;
|
||||
|
||||
// Update mouse position for smooth dragging
|
||||
this.dragMouseX = e.clientX;
|
||||
this.dragMouseY = e.clientY;
|
||||
|
||||
const containerRect = this.getBoundingClientRect();
|
||||
const margins = this.getMargins();
|
||||
const cellHeightValue = this.getCellHeight();
|
||||
|
||||
// Get widget position relative to grid container
|
||||
const mouseX = e.clientX - containerRect.left - this.dragOffsetX;
|
||||
const mouseY = e.clientY - containerRect.top - this.dragOffsetY;
|
||||
|
||||
// Use pixel calculations for accuracy
|
||||
const totalWidth = containerRect.width;
|
||||
const totalMarginWidth = margins.horizontal * (this.columns + 1);
|
||||
const availableWidth = totalWidth - totalMarginWidth;
|
||||
const cellWidthPx = availableWidth / this.columns;
|
||||
|
||||
// Calculate grid X position
|
||||
// Account for the initial margin and then repeating pattern of cell+margin
|
||||
let gridX = 0;
|
||||
if (mouseX > margins.horizontal) {
|
||||
const adjustedX = mouseX - margins.horizontal;
|
||||
const cellPlusMargin = cellWidthPx + margins.horizontal;
|
||||
gridX = Math.floor(adjustedX / cellPlusMargin + 0.5); // +0.5 for rounding to nearest
|
||||
}
|
||||
|
||||
// Calculate grid Y position
|
||||
let gridY = 0;
|
||||
if (mouseY > margins.vertical) {
|
||||
const adjustedY = mouseY - margins.vertical;
|
||||
const cellPlusMargin = cellHeightValue + margins.vertical;
|
||||
gridY = Math.floor(adjustedY / cellPlusMargin + 0.5); // +0.5 for rounding to nearest
|
||||
}
|
||||
|
||||
const clampedX = Math.max(0, Math.min(gridX, this.columns - this.draggedWidget.w));
|
||||
const clampedY = Math.max(0, gridY);
|
||||
|
||||
// Update placeholder position instead of widget position during drag
|
||||
if (!this.placeholderPosition ||
|
||||
clampedX !== this.placeholderPosition.x ||
|
||||
clampedY !== this.placeholderPosition.y) {
|
||||
const collision = this.checkCollision(this.draggedWidget, clampedX, clampedY);
|
||||
if (!collision) {
|
||||
this.placeholderPosition = { x: clampedX, y: clampedY };
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private endDrag = () => {
|
||||
// Apply final position from placeholder
|
||||
if (this.draggedWidget && this.placeholderPosition) {
|
||||
this.draggedWidget.x = this.placeholderPosition.x;
|
||||
this.draggedWidget.y = this.placeholderPosition.y;
|
||||
|
||||
this.dispatchEvent(new CustomEvent('widget-move', {
|
||||
detail: { widget: this.draggedWidget },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
// Clear drag state
|
||||
this.draggedWidget = null;
|
||||
this.draggedElement = null;
|
||||
this.placeholderPosition = null;
|
||||
this.dragMouseX = 0;
|
||||
this.dragMouseY = 0;
|
||||
|
||||
document.removeEventListener('mousemove', this.handleDrag);
|
||||
document.removeEventListener('mouseup', this.endDrag);
|
||||
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
private startResize(e: MouseEvent, widget: IDashboardWidget, handle: string) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.resizingWidget = widget;
|
||||
this.resizeStartW = widget.w;
|
||||
this.resizeStartH = widget.h;
|
||||
this.resizeStartX = e.clientX;
|
||||
this.resizeStartY = e.clientY;
|
||||
|
||||
const handleResize = (e: MouseEvent) => {
|
||||
if (!this.resizingWidget) return;
|
||||
|
||||
const containerRect = this.getBoundingClientRect();
|
||||
const margins = this.getMargins();
|
||||
const cellHeightValue = this.getCellHeight();
|
||||
const cellWidth = (containerRect.width - margins.horizontal * (this.columns + 1)) / this.columns;
|
||||
|
||||
const deltaX = e.clientX - this.resizeStartX;
|
||||
const deltaY = e.clientY - this.resizeStartY;
|
||||
|
||||
if (handle.includes('e')) {
|
||||
const newW = Math.round(this.resizeStartW + deltaX / (cellWidth + margins.horizontal));
|
||||
const maxW = widget.maxW || (this.columns - this.resizingWidget.x);
|
||||
this.resizingWidget.w = Math.max(widget.minW || 1, Math.min(newW, maxW));
|
||||
}
|
||||
|
||||
if (handle.includes('s')) {
|
||||
const newH = Math.round(this.resizeStartH + deltaY / (cellHeightValue + margins.vertical));
|
||||
const maxH = widget.maxH || Infinity;
|
||||
this.resizingWidget.h = Math.max(widget.minH || 1, Math.min(newH, maxH));
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
|
||||
this.dispatchEvent(new CustomEvent('widget-resize', {
|
||||
detail: { widget: this.resizingWidget },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
};
|
||||
|
||||
const endResize = () => {
|
||||
this.resizingWidget = null;
|
||||
document.removeEventListener('mousemove', handleResize);
|
||||
document.removeEventListener('mouseup', endResize);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleResize);
|
||||
document.addEventListener('mouseup', endResize);
|
||||
}
|
||||
|
||||
|
||||
public removeWidget(widgetId: string) {
|
||||
this.widgets = this.widgets.filter(w => w.id !== widgetId);
|
||||
}
|
||||
|
||||
public updateWidget(widgetId: string, updates: Partial<IDashboardWidget>) {
|
||||
this.widgets = this.widgets.map(w =>
|
||||
w.id === widgetId ? { ...w, ...updates } : w
|
||||
);
|
||||
}
|
||||
|
||||
public getLayout(): Array<{ id: string; x: number; y: number; w: number; h: number }> {
|
||||
return this.widgets.map(({ id, x, y, w, h }) => ({ id, x, y, w, h }));
|
||||
}
|
||||
|
||||
public setLayout(layout: Array<{ id: string; x: number; y: number; w: number; h: number }>) {
|
||||
this.widgets = this.widgets.map(widget => {
|
||||
const layoutItem = layout.find(l => l.id === widget.id);
|
||||
return layoutItem ? { ...widget, ...layoutItem } : widget;
|
||||
});
|
||||
}
|
||||
|
||||
public lockGrid() {
|
||||
this.editable = false;
|
||||
}
|
||||
|
||||
public unlockGrid() {
|
||||
this.editable = true;
|
||||
}
|
||||
|
||||
private getMargins(): { horizontal: number; vertical: number; top: number; right: number; bottom: number; left: number } {
|
||||
if (typeof this.margin === 'number') {
|
||||
return {
|
||||
horizontal: this.margin,
|
||||
vertical: this.margin,
|
||||
top: this.margin,
|
||||
right: this.margin,
|
||||
bottom: this.margin,
|
||||
left: this.margin,
|
||||
};
|
||||
}
|
||||
|
||||
const margins = {
|
||||
top: this.margin.top ?? 10,
|
||||
right: this.margin.right ?? 10,
|
||||
bottom: this.margin.bottom ?? 10,
|
||||
left: this.margin.left ?? 10,
|
||||
};
|
||||
|
||||
return {
|
||||
...margins,
|
||||
horizontal: (margins.left + margins.right) / 2,
|
||||
vertical: (margins.top + margins.bottom) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
private getCellHeight(): number {
|
||||
if (this.cellHeightUnit === 'auto') {
|
||||
// Calculate square cells based on container width
|
||||
const containerWidth = this.getBoundingClientRect().width;
|
||||
const margins = this.getMargins();
|
||||
const cellWidth = (containerWidth - margins.horizontal * (this.columns + 1)) / this.columns;
|
||||
return cellWidth;
|
||||
}
|
||||
|
||||
return this.cellHeight;
|
||||
}
|
||||
|
||||
private checkCollision(widget: IDashboardWidget, newX: number, newY: number): boolean {
|
||||
const widgets = this.widgets.filter(w => w.id !== widget.id);
|
||||
|
||||
for (const other of widgets) {
|
||||
if (newX < other.x + other.w &&
|
||||
newX + widget.w > other.x &&
|
||||
newY < other.y + other.h &&
|
||||
newY + widget.h > other.y) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public addWidget(widget: IDashboardWidget, autoPosition = false) {
|
||||
if (autoPosition || widget.autoPosition) {
|
||||
// Find first available position
|
||||
const position = this.findAvailablePosition(widget.w, widget.h);
|
||||
widget.x = position.x;
|
||||
widget.y = position.y;
|
||||
}
|
||||
|
||||
this.widgets = [...this.widgets, widget];
|
||||
}
|
||||
|
||||
private findAvailablePosition(width: number, height: number): { x: number; y: number } {
|
||||
// Try to find space starting from top-left
|
||||
for (let y = 0; y < 100; y++) { // Reasonable limit
|
||||
for (let x = 0; x <= this.columns - width; x++) {
|
||||
const testWidget = { id: 'test', x, y, w: width, h: height, content: '' } as IDashboardWidget;
|
||||
if (!this.checkCollision(testWidget, x, y)) {
|
||||
return { x, y };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no space found, place at bottom
|
||||
const maxY = Math.max(...this.widgets.map(w => w.y + w.h), 0);
|
||||
return { x: 0, y: maxY };
|
||||
}
|
||||
|
||||
public compact(direction: 'vertical' | 'horizontal' = 'vertical') {
|
||||
const sortedWidgets = [...this.widgets].sort((a, b) => {
|
||||
if (direction === 'vertical') {
|
||||
if (a.y !== b.y) return a.y - b.y;
|
||||
return a.x - b.x;
|
||||
} else {
|
||||
if (a.x !== b.x) return a.x - b.x;
|
||||
return a.y - b.y;
|
||||
}
|
||||
});
|
||||
|
||||
for (const widget of sortedWidgets) {
|
||||
if (widget.locked || widget.noMove) continue;
|
||||
|
||||
if (direction === 'vertical') {
|
||||
// Move up as far as possible
|
||||
while (widget.y > 0 && !this.checkCollision(widget, widget.x, widget.y - 1)) {
|
||||
widget.y--;
|
||||
}
|
||||
} else {
|
||||
// Move left as far as possible
|
||||
while (widget.x > 0 && !this.checkCollision(widget, widget.x - 1, widget.y)) {
|
||||
widget.x--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
47
ts_web/elements/dees-dashboardgrid/README.md
Normal file
47
ts_web/elements/dees-dashboardgrid/README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# dees-dashboardgrid
|
||||
|
||||
`<dees-dashboardgrid>` renders a configurable dashboard layout with draggable and resizable tiles. The component is now grouped in its own folder alongside supporting utilities and styles.
|
||||
|
||||
## Key Features
|
||||
|
||||
- Pointer-driven drag and resize interactions with keyboard fallbacks (arrow keys to move, `Shift` + arrows to resize).
|
||||
- Collision-aware placement that swaps compatible tiles or displaces blocking tiles into the next free slot.
|
||||
- Context menu (right-click on a tile header) that exposes destructive actions such as tile removal via `dees-contextmenu`.
|
||||
- Layout persistence helpers via `getLayout()`, `setLayout(...)`, and the `layout-change` event.
|
||||
- Responsive presets through the `layouts` map and `applyBreakpointLayout(...)` helper to hydrate per-breakpoint arrangements.
|
||||
|
||||
## Public API Highlights
|
||||
|
||||
| Property | Description |
|
||||
| --- | --- |
|
||||
| `widgets` | Array of tile descriptors (`DashboardWidget`). |
|
||||
| `columns` | Number of grid columns. |
|
||||
| `layouts` | Optional record of named layout definitions. |
|
||||
| `activeBreakpoint` | Name of the currently applied breakpoint layout. |
|
||||
| `editable` | Toggles drag/resize affordances. |
|
||||
|
||||
| Method | Description |
|
||||
| --- | --- |
|
||||
| `addWidget(widget, autoPosition?)` | Adds a tile, optionally auto-placing it into the next free slot. |
|
||||
| `removeWidget(id)` | Removes a tile and emits `widget-remove`. |
|
||||
| `applyBreakpointLayout(name)` | Applies a layout from the `layouts` map. |
|
||||
| `getLayout()` / `setLayout(layout)` | Retrieve or apply persisted layouts. |
|
||||
| `compact(direction?)` | Densifies the grid vertically (default) or horizontally. |
|
||||
|
||||
| Event | Detail payload |
|
||||
| --- | --- |
|
||||
| `widget-move` | `{ widget, displaced, swappedWith }` |
|
||||
| `widget-resize` | `{ widget, displaced, swappedWith }` |
|
||||
| `widget-remove` | `{ widget }` |
|
||||
| `layout-change` | `{ layout }` |
|
||||
|
||||
## Usage Notes
|
||||
|
||||
- **Right-click** a tile header to open the contextual menu and delete the tile.
|
||||
- When resizing, blocking tiles will automatically reflow into free space once the interaction completes.
|
||||
- Listen to `layout-change` to persist layouts to storage; rehydrate using `setLayout` or the `layouts` map.
|
||||
- For responsive dashboards, populate `grid.layouts = { base: [...], mobile: [...] }` and call `applyBreakpointLayout` based on your own breakpoint logic (see the co-located demo for an example).
|
||||
|
||||
## Demo
|
||||
|
||||
The updated `dees-dashboardgrid.demo.ts` showcases live breakpoint switching, layout persistence, and the context menu. Run the demo gallery to explore the interactions end-to-end.
|
29
ts_web/elements/dees-dashboardgrid/contextmenu.ts
Normal file
29
ts_web/elements/dees-dashboardgrid/contextmenu.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { DashboardWidget } from './types.js';
|
||||
import { DeesContextmenu } from '../dees-contextmenu.js';
|
||||
import type { DeesDashboardgrid } from './dees-dashboardgrid.js';
|
||||
import * as plugins from '../00plugins.js';
|
||||
|
||||
export interface WidgetContextMenuOptions {
|
||||
widget: DashboardWidget;
|
||||
host: DeesDashboardgrid;
|
||||
event: MouseEvent;
|
||||
}
|
||||
|
||||
export const openWidgetContextMenu = ({
|
||||
widget,
|
||||
host,
|
||||
event,
|
||||
}: WidgetContextMenuOptions) => {
|
||||
const items: (plugins.tsclass.website.IMenuItem | { divider: true })[] = [
|
||||
{
|
||||
name: 'Delete tile',
|
||||
iconName: 'lucide:trash2' as any,
|
||||
action: async () => {
|
||||
host.removeWidget(widget.id);
|
||||
return null;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
DeesContextmenu.openContextMenuWithOptions(event, items as any);
|
||||
};
|
405
ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.demo.ts
Normal file
405
ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.demo.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
import { html, css, cssManager } from '@design.estate/dees-element';
|
||||
import type { DeesDashboardgrid } from './dees-dashboardgrid.js';
|
||||
import '@design.estate/dees-wcctools/demotools';
|
||||
|
||||
export const demoFunc = () => {
|
||||
return html`
|
||||
<dees-demowrapper .runAfterRender=${async (elementArg: HTMLElement) => {
|
||||
const grid = elementArg.querySelector('#dashboardGrid') as DeesDashboardgrid;
|
||||
|
||||
const seedWidgets = [
|
||||
{
|
||||
id: 'metrics1',
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 3,
|
||||
h: 2,
|
||||
title: 'Revenue',
|
||||
icon: 'lucide:dollarSign',
|
||||
content: html`
|
||||
<div style="padding: 20px;">
|
||||
<div style="font-size: 32px; font-weight: 700; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">$124,563</div>
|
||||
<div style="color: #22c55e; font-size: 14px; margin-top: 8px;">↑ 12.5% from last month</div>
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'metrics2',
|
||||
x: 3,
|
||||
y: 0,
|
||||
w: 3,
|
||||
h: 2,
|
||||
title: 'Users',
|
||||
icon: 'lucide:users',
|
||||
content: html`
|
||||
<div style="padding: 20px;">
|
||||
<div style="font-size: 32px; font-weight: 700; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">8,234</div>
|
||||
<div style="color: #3b82f6; font-size: 14px; margin-top: 8px;">↑ 5.2% from last week</div>
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'chart1',
|
||||
x: 6,
|
||||
y: 0,
|
||||
w: 6,
|
||||
h: 4,
|
||||
title: 'Analytics',
|
||||
icon: 'lucide:lineChart',
|
||||
content: html`
|
||||
<div style="padding: 20px; height: 100%; display: flex; align-items: center; justify-content: center;">
|
||||
<div style="text-align: center; color: #71717a;">
|
||||
<dees-icon .icon=${'lucide:lineChart'} style="font-size: 48px; margin-bottom: 16px;"></dees-icon>
|
||||
<div>Chart visualization area</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
grid.widgets = seedWidgets.map(widget => ({ ...widget }));
|
||||
grid.cellHeight = 80;
|
||||
grid.margin = { top: 10, right: 10, bottom: 10, left: 10 };
|
||||
grid.enableAnimation = true;
|
||||
grid.showGridLines = false;
|
||||
|
||||
const baseLayout = grid.getLayout().map(item => ({ ...item }));
|
||||
const mobileLayout = grid.widgets.map((widget, index) => ({
|
||||
id: widget.id,
|
||||
x: 0,
|
||||
y: index === 0 ? 0 : grid.widgets.slice(0, index).reduce((acc, prev) => acc + prev.h, 0),
|
||||
w: grid.columns,
|
||||
h: widget.h,
|
||||
}));
|
||||
|
||||
grid.layouts = {
|
||||
base: baseLayout,
|
||||
mobile: mobileLayout,
|
||||
};
|
||||
|
||||
const statusEl = elementArg.querySelector('#dashboardLayoutStatus') as HTMLElement;
|
||||
const updateStatus = () => {
|
||||
const layout = grid.getLayout();
|
||||
statusEl.textContent = `Active breakpoint: ${grid.activeBreakpoint} • Tiles: ${layout.length}`;
|
||||
};
|
||||
|
||||
const mediaQuery = window.matchMedia('(max-width: 768px)');
|
||||
const handleBreakpoint = () => {
|
||||
const target = mediaQuery.matches ? 'mobile' : 'base';
|
||||
grid.applyBreakpointLayout(target);
|
||||
updateStatus();
|
||||
};
|
||||
if (typeof mediaQuery.addEventListener === 'function') {
|
||||
mediaQuery.addEventListener('change', handleBreakpoint);
|
||||
} else {
|
||||
(mediaQuery as MediaQueryList & {
|
||||
addListener?: (listener: (this: MediaQueryList, ev: MediaQueryListEvent) => void) => void;
|
||||
}).addListener?.(handleBreakpoint);
|
||||
}
|
||||
handleBreakpoint();
|
||||
|
||||
let widgetCounter = 4;
|
||||
|
||||
const buttons = elementArg.querySelectorAll('dees-button');
|
||||
buttons.forEach(button => {
|
||||
const text = button.textContent?.trim();
|
||||
|
||||
switch (text) {
|
||||
case 'Toggle Animation':
|
||||
button.addEventListener('click', () => {
|
||||
grid.enableAnimation = !grid.enableAnimation;
|
||||
});
|
||||
break;
|
||||
case 'Toggle Grid Lines':
|
||||
button.addEventListener('click', () => {
|
||||
grid.showGridLines = !grid.showGridLines;
|
||||
});
|
||||
break;
|
||||
case 'Add Widget':
|
||||
button.addEventListener('click', () => {
|
||||
const newWidget = {
|
||||
id: `widget${widgetCounter++}`,
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: 3,
|
||||
h: 2,
|
||||
autoPosition: true,
|
||||
title: `Widget ${widgetCounter - 1}`,
|
||||
icon: 'lucide:package',
|
||||
content: html`
|
||||
<div style="padding: 20px; text-align: center;">
|
||||
<div style="color: #71717a;">New widget content</div>
|
||||
<div style="margin-top: 8px; font-size: 24px; font-weight: 600; color: ${cssManager.bdTheme('#09090b', '#fafafa')};">${Math.floor(
|
||||
Math.random() * 1000,
|
||||
)}</div>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
grid.addWidget(newWidget, true);
|
||||
});
|
||||
break;
|
||||
case 'Compact Grid':
|
||||
button.addEventListener('click', () => {
|
||||
grid.compact();
|
||||
});
|
||||
break;
|
||||
case 'Toggle Edit Mode':
|
||||
button.addEventListener('click', () => {
|
||||
grid.editable = !grid.editable;
|
||||
button.textContent = grid.editable ? 'Lock Grid' : 'Unlock Grid';
|
||||
});
|
||||
break;
|
||||
case 'Reset Layout':
|
||||
button.addEventListener('click', () => {
|
||||
grid.applyBreakpointLayout(grid.activeBreakpoint);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Enhanced logging for reflow events
|
||||
let lastPlaceholderPosition = null;
|
||||
let moveEventCounter = 0;
|
||||
|
||||
// Helper function to log grid state
|
||||
const logGridState = (eventName: string, details?: any) => {
|
||||
const layout = grid.getLayout();
|
||||
console.group(`🔄 ${eventName} [Event #${++moveEventCounter}]`);
|
||||
console.log('Timestamp:', new Date().toISOString());
|
||||
console.log('Grid Configuration:', {
|
||||
columns: grid.columns,
|
||||
cellHeight: grid.cellHeight,
|
||||
margin: grid.margin,
|
||||
editable: grid.editable,
|
||||
activeBreakpoint: grid.activeBreakpoint
|
||||
});
|
||||
console.log('Current Layout:', layout);
|
||||
console.log('Widget Count:', layout.length);
|
||||
console.log('Grid Bounds:', {
|
||||
totalWidgets: grid.widgets.length,
|
||||
maxY: Math.max(...layout.map(w => w.y + w.h)),
|
||||
occupied: layout.map(w => `${w.id}: (${w.x},${w.y}) ${w.w}x${w.h}`).join(', ')
|
||||
});
|
||||
if (details) {
|
||||
console.log('Event Details:', details);
|
||||
}
|
||||
console.groupEnd();
|
||||
};
|
||||
|
||||
// Monitor placeholder position changes using MutationObserver
|
||||
const placeholderObserver = new MutationObserver(() => {
|
||||
const placeholder = grid.shadowRoot?.querySelector('.placeholder') as HTMLElement;
|
||||
if (placeholder) {
|
||||
const currentPosition = {
|
||||
left: placeholder.style.left,
|
||||
top: placeholder.style.top,
|
||||
width: placeholder.style.width,
|
||||
height: placeholder.style.height
|
||||
};
|
||||
|
||||
if (JSON.stringify(currentPosition) !== JSON.stringify(lastPlaceholderPosition)) {
|
||||
console.group('📍 Placeholder Position Changed');
|
||||
console.log('Previous:', lastPlaceholderPosition);
|
||||
console.log('Current:', currentPosition);
|
||||
|
||||
// Extract grid coordinates from style
|
||||
const gridInfo = grid.shadowRoot?.querySelector('.grid-container');
|
||||
if (gridInfo) {
|
||||
console.log('Grid Container Dimensions:', {
|
||||
width: gridInfo.clientWidth,
|
||||
height: gridInfo.clientHeight
|
||||
});
|
||||
}
|
||||
console.groupEnd();
|
||||
lastPlaceholderPosition = currentPosition;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Start observing the shadow DOM for placeholder changes
|
||||
if (grid.shadowRoot) {
|
||||
placeholderObserver.observe(grid.shadowRoot, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['style']
|
||||
});
|
||||
}
|
||||
|
||||
// Log initial state
|
||||
logGridState('Initial Grid State');
|
||||
|
||||
grid.addEventListener('widget-move', (e: CustomEvent) => {
|
||||
logGridState('Widget Move', {
|
||||
widget: e.detail.widget,
|
||||
displaced: e.detail.displaced,
|
||||
swappedWith: e.detail.swappedWith
|
||||
});
|
||||
});
|
||||
|
||||
grid.addEventListener('widget-resize', (e: CustomEvent) => {
|
||||
logGridState('Widget Resize', {
|
||||
widget: e.detail.widget,
|
||||
displaced: e.detail.displaced,
|
||||
swappedWith: e.detail.swappedWith
|
||||
});
|
||||
});
|
||||
|
||||
grid.addEventListener('widget-remove', (e: CustomEvent) => {
|
||||
logGridState('Widget Remove', {
|
||||
removedWidget: e.detail.widget
|
||||
});
|
||||
updateStatus();
|
||||
});
|
||||
|
||||
grid.addEventListener('layout-change', () => {
|
||||
logGridState('Layout Change');
|
||||
updateStatus();
|
||||
});
|
||||
|
||||
// Monitor during drag/resize operations using pointer events
|
||||
grid.addEventListener('pointerdown', (e: PointerEvent) => {
|
||||
const isHeader = (e.target as HTMLElement).closest('.widget-header');
|
||||
const isResizeHandle = (e.target as HTMLElement).closest('.resize-handle');
|
||||
|
||||
if (isHeader || isResizeHandle) {
|
||||
console.group(`🎯 Interaction Started: ${isHeader ? 'Drag' : 'Resize'}`);
|
||||
console.log('Target Widget:', (e.target as HTMLElement).closest('.widget')?.getAttribute('data-widget-id'));
|
||||
console.log('Pointer Position:', { x: e.clientX, y: e.clientY });
|
||||
console.groupEnd();
|
||||
|
||||
// Track pointer move during interaction
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
const widget = (e.target as HTMLElement).closest('.widget');
|
||||
if (widget) {
|
||||
console.log(`↔️ Pointer Move:`, {
|
||||
widgetId: widget.getAttribute('data-widget-id'),
|
||||
position: { x: moveEvent.clientX, y: moveEvent.clientY },
|
||||
delta: {
|
||||
x: moveEvent.clientX - e.clientX,
|
||||
y: moveEvent.clientY - e.clientY
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
console.group('🏁 Interaction Ended');
|
||||
logGridState('Final State After Interaction');
|
||||
console.groupEnd();
|
||||
document.removeEventListener('pointermove', handlePointerMove);
|
||||
document.removeEventListener('pointerup', handlePointerUp);
|
||||
};
|
||||
|
||||
document.addEventListener('pointermove', handlePointerMove);
|
||||
document.addEventListener('pointerup', handlePointerUp);
|
||||
}
|
||||
});
|
||||
|
||||
// Log when widgets are added
|
||||
const originalAddWidget = grid.addWidget.bind(grid);
|
||||
grid.addWidget = (widget: any, autoPosition?: boolean) => {
|
||||
console.group('➕ Adding Widget');
|
||||
console.log('New Widget:', widget);
|
||||
console.log('Auto Position:', autoPosition);
|
||||
const result = originalAddWidget(widget, autoPosition);
|
||||
logGridState('After Widget Added');
|
||||
console.groupEnd();
|
||||
return result;
|
||||
};
|
||||
|
||||
// Log compact operations
|
||||
const originalCompact = grid.compact.bind(grid);
|
||||
grid.compact = (direction?: string) => {
|
||||
console.group('🗜️ Compacting Grid');
|
||||
console.log('Direction:', direction || 'vertical');
|
||||
logGridState('Before Compact');
|
||||
const result = originalCompact(direction);
|
||||
logGridState('After Compact');
|
||||
console.groupEnd();
|
||||
return result;
|
||||
};
|
||||
|
||||
updateStatus();
|
||||
}}>
|
||||
<style>
|
||||
${css`
|
||||
.demoBox {
|
||||
position: relative;
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#09090b')};
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 40px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.demo-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.demo-controls dees-button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.grid-container-wrapper {
|
||||
flex: 1;
|
||||
min-height: 600px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.info {
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
font-size: 12px;
|
||||
font-family: 'Geist Sans', sans-serif;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#dashboardLayoutStatus {
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div class="demoBox">
|
||||
<div class="demo-controls">
|
||||
<dees-button-group label="Animation:">
|
||||
<dees-button>Toggle Animation</dees-button>
|
||||
</dees-button-group>
|
||||
|
||||
<dees-button-group label="Display:">
|
||||
<dees-button>Toggle Grid Lines</dees-button>
|
||||
</dees-button-group>
|
||||
|
||||
<dees-button-group label="Actions:">
|
||||
<dees-button>Add Widget</dees-button>
|
||||
<dees-button>Compact Grid</dees-button>
|
||||
<dees-button>Reset Layout</dees-button>
|
||||
</dees-button-group>
|
||||
|
||||
<dees-button-group label="Mode:">
|
||||
<dees-button>Toggle Edit Mode</dees-button>
|
||||
</dees-button-group>
|
||||
</div>
|
||||
|
||||
<div class="grid-container-wrapper">
|
||||
<dees-dashboardgrid id="dashboardGrid"></dees-dashboardgrid>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<div>Drag to reposition, resize from handles, or right-click a header to delete a tile.</div>
|
||||
<div id="dashboardLayoutStatus"></div>
|
||||
</div>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
};
|
796
ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.ts
Normal file
796
ts_web/elements/dees-dashboardgrid/dees-dashboardgrid.ts
Normal file
@@ -0,0 +1,796 @@
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
property,
|
||||
state,
|
||||
html,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import '../dees-icon.js';
|
||||
import '../dees-contextmenu.js';
|
||||
import { demoFunc } from './dees-dashboardgrid.demo.js';
|
||||
import { dashboardGridStyles } from './styles.js';
|
||||
import {
|
||||
resolveMargins,
|
||||
calculateCellMetrics,
|
||||
calculateGridHeight,
|
||||
findAvailablePosition,
|
||||
compactLayout,
|
||||
applyLayout,
|
||||
resolveWidgetPlacement,
|
||||
type PlacementResult,
|
||||
} from './layout.js';
|
||||
import {
|
||||
computeGridCoordinates,
|
||||
computeResizeDimensions,
|
||||
type PointerPosition,
|
||||
} from './interaction.js';
|
||||
import { openWidgetContextMenu } from './contextmenu.js';
|
||||
import type {
|
||||
DashboardWidget,
|
||||
DashboardMargin,
|
||||
DashboardResolvedMargins,
|
||||
GridCellMetrics,
|
||||
DashboardLayoutItem,
|
||||
LayoutDirection,
|
||||
CellHeightUnit,
|
||||
} from './types.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-dashboardgrid': DeesDashboardgrid;
|
||||
}
|
||||
}
|
||||
|
||||
type DragState = {
|
||||
widgetId: string;
|
||||
pointerId: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
start: DashboardLayoutItem;
|
||||
previousPosition: DashboardLayoutItem;
|
||||
currentPointer: PointerPosition;
|
||||
lastPlacement: PlacementResult | null;
|
||||
};
|
||||
|
||||
type ResizeState = {
|
||||
widgetId: string;
|
||||
pointerId: number;
|
||||
handler: 'e' | 's' | 'se';
|
||||
startPointer: PointerPosition;
|
||||
start: DashboardLayoutItem;
|
||||
startWidth: number;
|
||||
startHeight: number;
|
||||
lastPlacement: PlacementResult | null;
|
||||
};
|
||||
|
||||
@customElement('dees-dashboardgrid')
|
||||
export class DeesDashboardgrid extends DeesElement {
|
||||
public static demo = demoFunc;
|
||||
public static styles = dashboardGridStyles;
|
||||
|
||||
@property({ type: Array })
|
||||
public widgets: DashboardWidget[] = [];
|
||||
|
||||
@property({ type: Number })
|
||||
public cellHeight: number = 80;
|
||||
|
||||
@property({ type: Object })
|
||||
public margin: DashboardMargin = 10;
|
||||
|
||||
@property({ type: Number })
|
||||
public columns: number = 12;
|
||||
|
||||
@property({ type: Boolean })
|
||||
public editable: boolean = true;
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public enableAnimation: boolean = true;
|
||||
|
||||
@property({ type: String })
|
||||
public cellHeightUnit: CellHeightUnit = 'px';
|
||||
|
||||
@property({ type: Boolean })
|
||||
public rtl: boolean = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
public showGridLines: boolean = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
public layouts?: Record<string, DashboardLayoutItem[]>;
|
||||
|
||||
@property({ type: String })
|
||||
public activeBreakpoint: string = 'base';
|
||||
|
||||
@state()
|
||||
private placeholderPosition: DashboardLayoutItem | null = null;
|
||||
|
||||
@state()
|
||||
private metrics: GridCellMetrics | null = null;
|
||||
|
||||
@state()
|
||||
private resolvedMargins: DashboardResolvedMargins | null = null;
|
||||
|
||||
@state()
|
||||
private previewWidgets: DashboardWidget[] | null = null;
|
||||
|
||||
private containerBounds: DOMRect | null = null;
|
||||
private dragState: DragState | null = null;
|
||||
private resizeState: ResizeState | null = null;
|
||||
private resizeObserver?: ResizeObserver;
|
||||
private interactionActive = false;
|
||||
|
||||
public override async connectedCallback(): Promise<void> {
|
||||
await super.connectedCallback();
|
||||
this.computeMetrics();
|
||||
this.observeResize();
|
||||
}
|
||||
|
||||
public override async disconnectedCallback(): Promise<void> {
|
||||
await super.disconnectedCallback();
|
||||
this.disconnectResizeObserver();
|
||||
this.releasePointerEvents();
|
||||
}
|
||||
|
||||
protected updated(changed: Map<string, unknown>): void {
|
||||
if (
|
||||
changed.has('margin') ||
|
||||
changed.has('columns') ||
|
||||
changed.has('cellHeight') ||
|
||||
changed.has('cellHeightUnit')
|
||||
) {
|
||||
this.computeMetrics();
|
||||
}
|
||||
|
||||
if (changed.has('widgets') && !this.interactionActive) {
|
||||
this.notifyLayoutChange();
|
||||
}
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
const baseWidgets = this.widgets;
|
||||
if (baseWidgets.length === 0) {
|
||||
return html`
|
||||
<div class="empty-state">
|
||||
<dees-icon .icon=${'lucide:layoutGrid'}></dees-icon>
|
||||
<div>No widgets configured</div>
|
||||
<div style="font-size: 14px; margin-top: 8px;">Add widgets to populate the dashboard</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const metrics = this.ensureMetrics();
|
||||
const margins = this.resolvedMargins ?? resolveMargins(this.margin);
|
||||
const cellHeight = metrics.cellHeightPx;
|
||||
const layoutForHeight = this.previewWidgets ?? this.widgets;
|
||||
const gridHeight = calculateGridHeight(layoutForHeight, margins, cellHeight);
|
||||
const previewMap = this.previewWidgets ? new Map(this.previewWidgets.map(widget => [widget.id, widget])) : null;
|
||||
|
||||
return html`
|
||||
<div class="grid-container" style="height: ${gridHeight}px;">
|
||||
${this.showGridLines ? this.renderGridLines(metrics, gridHeight) : null}
|
||||
${baseWidgets.map(widget => this.renderWidget(widget, metrics, margins, previewMap))}
|
||||
${this.placeholderPosition ? this.renderPlaceholder(metrics, margins) : null}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderGridLines(metrics: GridCellMetrics, gridHeight: number): TemplateResult {
|
||||
const vertical: TemplateResult[] = [];
|
||||
const horizontal: TemplateResult[] = [];
|
||||
const cellPlusMarginX = metrics.cellWidthPx + metrics.marginHorizontalPx;
|
||||
const cellPlusMarginY = metrics.cellHeightPx + metrics.marginVerticalPx;
|
||||
|
||||
for (let i = 0; i <= this.columns; i++) {
|
||||
const leftPx = i * cellPlusMarginX + metrics.marginHorizontalPx;
|
||||
const leftPercent = this.pxToPercent(leftPx, metrics.containerWidth);
|
||||
vertical.push(html`<div class="grid-line-vertical" style="left: ${leftPercent}%;"></div>`);
|
||||
}
|
||||
|
||||
const rows = Math.ceil(gridHeight / cellPlusMarginY);
|
||||
for (let row = 0; row <= rows; row++) {
|
||||
const top = row * cellPlusMarginY;
|
||||
horizontal.push(html`<div class="grid-line-horizontal" style="top: ${top}px;"></div>`);
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="grid-lines">
|
||||
${vertical}
|
||||
${horizontal}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderWidget(
|
||||
widget: DashboardWidget,
|
||||
metrics: GridCellMetrics,
|
||||
margins: DashboardResolvedMargins,
|
||||
previewMap: Map<string, DashboardWidget> | null,
|
||||
): TemplateResult {
|
||||
const isDragging = this.dragState?.widgetId === widget.id;
|
||||
const isResizing = this.resizeState?.widgetId === widget.id;
|
||||
const isLocked = widget.locked || !this.editable;
|
||||
const previewWidget = previewMap?.get(widget.id) ?? null;
|
||||
const layoutForRender = isDragging ? widget : previewWidget ?? widget;
|
||||
const rect = this.computeWidgetRect(layoutForRender, metrics, margins);
|
||||
|
||||
const sideProperty = this.rtl ? 'right' : 'left';
|
||||
const sideValue = this.pxToPercent(rect.left, metrics.containerWidth);
|
||||
const widthPercent = this.pxToPercent(rect.width, metrics.containerWidth);
|
||||
|
||||
let transform = '';
|
||||
if (isDragging && this.dragState?.currentPointer) {
|
||||
const pointer = this.dragState.currentPointer;
|
||||
const bounds = this.containerBounds ?? this.getBoundingClientRect();
|
||||
const translateX = pointer.clientX - bounds.left - this.dragState.offsetX - rect.left;
|
||||
const translateY = pointer.clientY - bounds.top - this.dragState.offsetY - rect.top;
|
||||
transform = `transform: translate(${translateX}px, ${translateY}px);`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="grid-widget ${isDragging ? 'dragging' : ''} ${isResizing ? 'resizing' : ''}"
|
||||
style="
|
||||
${sideProperty}: ${sideValue}%;
|
||||
top: ${rect.top}px;
|
||||
width: ${widthPercent}%;
|
||||
height: ${rect.height}px;
|
||||
${transform}
|
||||
"
|
||||
data-widget-id=${widget.id}
|
||||
>
|
||||
<div class="widget-content">
|
||||
${widget.title
|
||||
? html`
|
||||
<div
|
||||
class="widget-header ${isLocked ? 'locked' : ''}"
|
||||
@pointerdown=${!isLocked && !widget.noMove
|
||||
? (evt: PointerEvent) => this.startDrag(evt, widget)
|
||||
: null}
|
||||
@contextmenu=${(evt: MouseEvent) => this.handleWidgetContextMenu(evt, widget)}
|
||||
tabindex=${!isLocked && !widget.noMove ? 0 : -1}
|
||||
@keydown=${(evt: KeyboardEvent) => this.handleHeaderKeydown(evt, widget)}
|
||||
>
|
||||
${widget.icon ? html`<dees-icon .icon=${widget.icon}></dees-icon>` : null}
|
||||
${widget.title}
|
||||
</div>
|
||||
`
|
||||
: null}
|
||||
<div class="widget-body ${widget.title ? 'has-header' : ''}">
|
||||
${widget.content}
|
||||
</div>
|
||||
${!isLocked && !widget.noResize
|
||||
? html`
|
||||
<div
|
||||
class="resize-handle resize-handle-e"
|
||||
@pointerdown=${(evt: PointerEvent) => this.startResize(evt, widget, 'e')}
|
||||
></div>
|
||||
<div
|
||||
class="resize-handle resize-handle-s"
|
||||
@pointerdown=${(evt: PointerEvent) => this.startResize(evt, widget, 's')}
|
||||
></div>
|
||||
<div
|
||||
class="resize-handle resize-handle-se"
|
||||
@pointerdown=${(evt: PointerEvent) => this.startResize(evt, widget, 'se')}
|
||||
></div>
|
||||
`
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPlaceholder(
|
||||
metrics: GridCellMetrics,
|
||||
margins: DashboardResolvedMargins,
|
||||
): TemplateResult {
|
||||
if (!this.placeholderPosition) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const rect = this.computeWidgetRect(this.placeholderPosition, metrics, margins);
|
||||
const sideProperty = this.rtl ? 'right' : 'left';
|
||||
const sideValue = this.pxToPercent(rect.left, metrics.containerWidth);
|
||||
const widthPercent = this.pxToPercent(rect.width, metrics.containerWidth);
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="grid-widget placeholder"
|
||||
style="
|
||||
${sideProperty}: ${sideValue}%;
|
||||
top: ${rect.top}px;
|
||||
width: ${widthPercent}%;
|
||||
height: ${rect.height}px;
|
||||
"
|
||||
>
|
||||
<div class="widget-content"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private startDrag(event: PointerEvent, widget: DashboardWidget): void {
|
||||
if (!this.editable || widget.noMove || widget.locked) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const widgetElement = (event.currentTarget as HTMLElement).closest('.grid-widget') as HTMLElement | null;
|
||||
if (!widgetElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const widgetRect = widgetElement.getBoundingClientRect();
|
||||
this.containerBounds = this.getBoundingClientRect();
|
||||
this.ensureMetrics();
|
||||
|
||||
this.dragState = {
|
||||
widgetId: widget.id,
|
||||
pointerId: event.pointerId,
|
||||
offsetX: event.clientX - widgetRect.left,
|
||||
offsetY: event.clientY - widgetRect.top,
|
||||
start: { id: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h },
|
||||
previousPosition: { id: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h },
|
||||
currentPointer: { clientX: event.clientX, clientY: event.clientY },
|
||||
lastPlacement: null,
|
||||
};
|
||||
|
||||
this.interactionActive = true;
|
||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||
document.addEventListener('pointermove', this.handleDragMove);
|
||||
document.addEventListener('pointerup', this.handleDragEnd);
|
||||
|
||||
this.placeholderPosition = { id: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h };
|
||||
}
|
||||
|
||||
private handleDragMove = (event: PointerEvent): void => {
|
||||
if (!this.dragState) return;
|
||||
const metrics = this.ensureMetrics();
|
||||
const activeWidgets = this.widgets;
|
||||
const widget = activeWidgets.find(item => item.id === this.dragState!.widgetId);
|
||||
if (!widget) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const previousPosition = this.dragState.previousPosition;
|
||||
|
||||
const coords = computeGridCoordinates({
|
||||
pointer: { clientX: event.clientX, clientY: event.clientY },
|
||||
containerRect: this.containerBounds ?? this.getBoundingClientRect(),
|
||||
metrics,
|
||||
columns: this.columns,
|
||||
widget,
|
||||
rtl: this.rtl,
|
||||
dragOffsetX: this.dragState.offsetX,
|
||||
dragOffsetY: this.dragState.offsetY,
|
||||
});
|
||||
|
||||
const placement = resolveWidgetPlacement(
|
||||
activeWidgets,
|
||||
widget.id,
|
||||
{ x: coords.x, y: coords.y },
|
||||
this.columns,
|
||||
previousPosition,
|
||||
);
|
||||
if (placement) {
|
||||
const updatedWidget = placement.widgets.find(item => item.id === widget.id);
|
||||
this.dragState = {
|
||||
...this.dragState,
|
||||
currentPointer: { clientX: event.clientX, clientY: event.clientY },
|
||||
lastPlacement: placement,
|
||||
previousPosition: updatedWidget
|
||||
? { id: updatedWidget.id, x: updatedWidget.x, y: updatedWidget.y, w: updatedWidget.w, h: updatedWidget.h }
|
||||
: { id: widget.id, x: coords.x, y: coords.y, w: widget.w, h: widget.h },
|
||||
};
|
||||
this.previewWidgets = placement.widgets;
|
||||
const previewWidget = placement.widgets.find(item => item.id === widget.id);
|
||||
if (previewWidget) {
|
||||
this.placeholderPosition = {
|
||||
id: previewWidget.id,
|
||||
x: previewWidget.x,
|
||||
y: previewWidget.y,
|
||||
w: previewWidget.w,
|
||||
h: previewWidget.h,
|
||||
};
|
||||
} else {
|
||||
this.placeholderPosition = { id: widget.id, x: coords.x, y: coords.y, w: widget.w, h: widget.h };
|
||||
}
|
||||
} else {
|
||||
this.previewWidgets = null;
|
||||
this.placeholderPosition = null;
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
private handleDragEnd = (event: PointerEvent): void => {
|
||||
const dragState = this.dragState;
|
||||
if (!dragState || event.pointerId !== dragState.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const layoutSource = this.widgets;
|
||||
this.previewWidgets = null;
|
||||
|
||||
// Always validate the final position, don't rely on lastPlacement from drag
|
||||
const target = this.placeholderPosition ?? dragState.start;
|
||||
const placement = resolveWidgetPlacement(
|
||||
layoutSource,
|
||||
dragState.widgetId,
|
||||
{ x: target.x, y: target.y },
|
||||
this.columns,
|
||||
dragState.previousPosition,
|
||||
);
|
||||
|
||||
if (placement) {
|
||||
// Verify that the placement doesn't result in overlapping widgets
|
||||
const finalWidget = placement.widgets.find(w => w.id === dragState.widgetId);
|
||||
if (finalWidget) {
|
||||
const hasOverlap = placement.widgets.some(w => {
|
||||
if (w.id === dragState.widgetId) return false;
|
||||
return (
|
||||
finalWidget.x < w.x + w.w &&
|
||||
finalWidget.x + finalWidget.w > w.x &&
|
||||
finalWidget.y < w.y + w.h &&
|
||||
finalWidget.y + finalWidget.h > w.y
|
||||
);
|
||||
});
|
||||
|
||||
if (!hasOverlap) {
|
||||
this.commitPlacement(placement, dragState.widgetId, 'widget-move');
|
||||
} else {
|
||||
// Return to start position if overlap detected
|
||||
this.widgets = this.widgets.map(widget =>
|
||||
widget.id === dragState.widgetId ? { ...widget, x: dragState.start.x, y: dragState.start.y } : widget,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Return to start position if no valid placement
|
||||
this.widgets = this.widgets.map(widget =>
|
||||
widget.id === dragState.widgetId ? { ...widget, x: dragState.start.x, y: dragState.start.y } : widget,
|
||||
);
|
||||
}
|
||||
|
||||
this.placeholderPosition = null;
|
||||
this.dragState = null;
|
||||
this.interactionActive = false;
|
||||
this.releasePointerEvents();
|
||||
};
|
||||
|
||||
private startResize(event: PointerEvent, widget: DashboardWidget, handler: 'e' | 's' | 'se'): void {
|
||||
if (!this.editable || widget.noResize || widget.locked) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.ensureMetrics();
|
||||
|
||||
this.resizeState = {
|
||||
widgetId: widget.id,
|
||||
pointerId: event.pointerId,
|
||||
handler,
|
||||
startPointer: { clientX: event.clientX, clientY: event.clientY },
|
||||
start: { id: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h },
|
||||
startWidth: widget.w,
|
||||
startHeight: widget.h,
|
||||
lastPlacement: null,
|
||||
};
|
||||
|
||||
this.interactionActive = true;
|
||||
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
||||
document.addEventListener('pointermove', this.handleResizeMove);
|
||||
document.addEventListener('pointerup', this.handleResizeEnd);
|
||||
|
||||
this.placeholderPosition = { id: widget.id, x: widget.x, y: widget.y, w: widget.w, h: widget.h };
|
||||
}
|
||||
|
||||
private handleResizeMove = (event: PointerEvent): void => {
|
||||
if (!this.resizeState) return;
|
||||
const metrics = this.ensureMetrics();
|
||||
const activeWidgets = this.widgets;
|
||||
const widget = activeWidgets.find(item => item.id === this.resizeState!.widgetId);
|
||||
if (!widget) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const nextSize = computeResizeDimensions({
|
||||
pointer: { clientX: event.clientX, clientY: event.clientY },
|
||||
containerRect: this.containerBounds ?? this.getBoundingClientRect(),
|
||||
metrics,
|
||||
startWidth: this.resizeState.startWidth,
|
||||
startHeight: this.resizeState.startHeight,
|
||||
startPointer: this.resizeState.startPointer,
|
||||
handler: this.resizeState.handler,
|
||||
widget,
|
||||
columns: this.columns,
|
||||
});
|
||||
|
||||
const placement = resolveWidgetPlacement(
|
||||
activeWidgets,
|
||||
widget.id,
|
||||
{ x: widget.x, y: widget.y, w: nextSize.width, h: nextSize.height },
|
||||
this.columns,
|
||||
this.resizeState.start,
|
||||
);
|
||||
|
||||
if (placement) {
|
||||
this.resizeState = { ...this.resizeState, lastPlacement: placement };
|
||||
this.previewWidgets = placement.widgets;
|
||||
const previewWidget = placement.widgets.find(item => item.id === widget.id);
|
||||
if (previewWidget) {
|
||||
this.placeholderPosition = {
|
||||
id: previewWidget.id,
|
||||
x: previewWidget.x,
|
||||
y: previewWidget.y,
|
||||
w: previewWidget.w,
|
||||
h: previewWidget.h,
|
||||
};
|
||||
} else {
|
||||
this.placeholderPosition = {
|
||||
id: widget.id,
|
||||
x: widget.x,
|
||||
y: widget.y,
|
||||
w: nextSize.width,
|
||||
h: nextSize.height,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
this.previewWidgets = null;
|
||||
this.placeholderPosition = null;
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
private handleResizeEnd = (event: PointerEvent): void => {
|
||||
const resizeState = this.resizeState;
|
||||
if (!resizeState || event.pointerId !== resizeState.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const layoutSource = this.widgets;
|
||||
this.previewWidgets = null;
|
||||
const placement =
|
||||
resizeState.lastPlacement ??
|
||||
resolveWidgetPlacement(
|
||||
layoutSource,
|
||||
resizeState.widgetId,
|
||||
{
|
||||
x: this.placeholderPosition?.x ?? resizeState.start.x,
|
||||
y: this.placeholderPosition?.y ?? resizeState.start.y,
|
||||
w: this.placeholderPosition?.w ?? resizeState.start.w,
|
||||
h: this.placeholderPosition?.h ?? resizeState.start.h,
|
||||
},
|
||||
this.columns,
|
||||
resizeState.start,
|
||||
);
|
||||
|
||||
if (placement) {
|
||||
this.commitPlacement(placement, resizeState.widgetId, 'widget-resize');
|
||||
} else {
|
||||
this.widgets = this.widgets.map(widget =>
|
||||
widget.id === resizeState.widgetId ? { ...widget, w: resizeState.start.w, h: resizeState.start.h } : widget,
|
||||
);
|
||||
}
|
||||
|
||||
this.placeholderPosition = null;
|
||||
this.resizeState = null;
|
||||
this.interactionActive = false;
|
||||
this.releasePointerEvents();
|
||||
};
|
||||
|
||||
private handleHeaderKeydown(event: KeyboardEvent, widget: DashboardWidget): void {
|
||||
if (!this.editable || widget.noMove || widget.locked) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key;
|
||||
const isResize = event.shiftKey;
|
||||
let placement: PlacementResult | null = null;
|
||||
|
||||
if (isResize && ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(key)) {
|
||||
event.preventDefault();
|
||||
const delta = key === 'ArrowRight' || key === 'ArrowDown' ? 1 : -1;
|
||||
|
||||
if (key === 'ArrowLeft' || key === 'ArrowRight') {
|
||||
const maxWidth = widget.maxW ?? this.columns - widget.x;
|
||||
const nextWidth = Math.max(widget.minW ?? 1, Math.min(maxWidth, widget.w + delta));
|
||||
placement = resolveWidgetPlacement(
|
||||
this.widgets,
|
||||
widget.id,
|
||||
{ x: widget.x, y: widget.y, w: nextWidth, h: widget.h },
|
||||
this.columns,
|
||||
);
|
||||
} else {
|
||||
const maxHeight = widget.maxH ?? Number.POSITIVE_INFINITY;
|
||||
const nextHeight = Math.max(widget.minH ?? 1, Math.min(maxHeight, widget.h + delta));
|
||||
placement = resolveWidgetPlacement(
|
||||
this.widgets,
|
||||
widget.id,
|
||||
{ x: widget.x, y: widget.y, w: widget.w, h: nextHeight },
|
||||
this.columns,
|
||||
);
|
||||
}
|
||||
|
||||
if (placement) {
|
||||
this.commitPlacement(placement, widget.id, 'widget-resize');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const moveMap: Record<string, { dx: number; dy: number }> = {
|
||||
ArrowLeft: { dx: -1, dy: 0 },
|
||||
ArrowRight: { dx: 1, dy: 0 },
|
||||
ArrowUp: { dx: 0, dy: -1 },
|
||||
ArrowDown: { dx: 0, dy: 1 },
|
||||
};
|
||||
|
||||
const delta = moveMap[key];
|
||||
if (!delta) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const targetX = Math.max(0, Math.min(this.columns - widget.w, widget.x + delta.dx));
|
||||
const targetY = Math.max(0, widget.y + delta.dy);
|
||||
|
||||
placement = resolveWidgetPlacement(this.widgets, widget.id, { x: targetX, y: targetY }, this.columns);
|
||||
if (placement) {
|
||||
this.commitPlacement(placement, widget.id, 'widget-move');
|
||||
}
|
||||
}
|
||||
|
||||
private handleWidgetContextMenu(event: MouseEvent, widget: DashboardWidget): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openWidgetContextMenu({ widget, host: this, event });
|
||||
}
|
||||
|
||||
private commitPlacement(result: PlacementResult, widgetId: string, type: 'widget-move' | 'widget-resize'): void {
|
||||
this.previewWidgets = null;
|
||||
this.widgets = result.widgets;
|
||||
const subject = this.widgets.find(item => item.id === widgetId);
|
||||
if (subject) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent(type, {
|
||||
detail: {
|
||||
widget: subject,
|
||||
displaced: result.movedWidgets.filter(id => id !== widgetId),
|
||||
swappedWith: result.swappedWith,
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public removeWidget(widgetId: string): void {
|
||||
const target = this.widgets.find(widget => widget.id === widgetId);
|
||||
if (!target) return;
|
||||
this.widgets = this.widgets.filter(widget => widget.id !== widgetId);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('widget-remove', {
|
||||
detail: { widget: target },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public updateWidget(widgetId: string, updates: Partial<DashboardWidget>): void {
|
||||
this.widgets = this.widgets.map(widget => (widget.id === widgetId ? { ...widget, ...updates } : widget));
|
||||
}
|
||||
|
||||
public getLayout(): DashboardLayoutItem[] {
|
||||
return this.widgets.map(({ id, x, y, w, h }) => ({ id, x, y, w, h }));
|
||||
}
|
||||
|
||||
public setLayout(layout: DashboardLayoutItem[]): void {
|
||||
this.widgets = applyLayout(this.widgets, layout);
|
||||
}
|
||||
|
||||
public lockGrid(): void {
|
||||
this.editable = false;
|
||||
}
|
||||
|
||||
public unlockGrid(): void {
|
||||
this.editable = true;
|
||||
}
|
||||
|
||||
public addWidget(widget: DashboardWidget, autoPosition = false): void {
|
||||
const nextWidget = { ...widget };
|
||||
if (autoPosition || nextWidget.autoPosition) {
|
||||
const position = findAvailablePosition(this.widgets, nextWidget.w, nextWidget.h, this.columns);
|
||||
nextWidget.x = position.x;
|
||||
nextWidget.y = position.y;
|
||||
}
|
||||
|
||||
this.widgets = [...this.widgets, nextWidget];
|
||||
}
|
||||
|
||||
public compact(direction: LayoutDirection = 'vertical'): void {
|
||||
const nextWidgets = this.widgets.map(widget => ({ ...widget }));
|
||||
compactLayout(nextWidgets, direction);
|
||||
this.widgets = nextWidgets;
|
||||
}
|
||||
|
||||
public applyBreakpointLayout(breakpoint: string): void {
|
||||
this.activeBreakpoint = breakpoint;
|
||||
const layout = this.layouts?.[breakpoint];
|
||||
if (layout) {
|
||||
this.setLayout(layout);
|
||||
}
|
||||
}
|
||||
|
||||
public notifyLayoutChange(): void {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('layout-change', {
|
||||
detail: { layout: this.getLayout() },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private ensureMetrics(): GridCellMetrics {
|
||||
if (!this.metrics) {
|
||||
this.computeMetrics();
|
||||
}
|
||||
return this.metrics!;
|
||||
}
|
||||
|
||||
private computeMetrics(): void {
|
||||
if (!this.isConnected) return;
|
||||
const bounds = this.getBoundingClientRect();
|
||||
this.containerBounds = bounds;
|
||||
const margins = resolveMargins(this.margin);
|
||||
this.resolvedMargins = margins;
|
||||
this.metrics = calculateCellMetrics(bounds.width, this.columns, margins, this.cellHeight, this.cellHeightUnit);
|
||||
}
|
||||
|
||||
private observeResize(): void {
|
||||
if (this.resizeObserver) return;
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.computeMetrics();
|
||||
});
|
||||
this.resizeObserver.observe(this);
|
||||
}
|
||||
|
||||
private disconnectResizeObserver(): void {
|
||||
this.resizeObserver?.disconnect();
|
||||
this.resizeObserver = undefined;
|
||||
}
|
||||
|
||||
private releasePointerEvents(): void {
|
||||
document.removeEventListener('pointermove', this.handleDragMove);
|
||||
document.removeEventListener('pointerup', this.handleDragEnd);
|
||||
document.removeEventListener('pointermove', this.handleResizeMove);
|
||||
document.removeEventListener('pointerup', this.handleResizeEnd);
|
||||
}
|
||||
|
||||
private pxToPercent(value: number, container: number): number {
|
||||
if (!container) return 0;
|
||||
return Number(((value / container) * 100).toFixed(4));
|
||||
}
|
||||
|
||||
private computeWidgetRect(
|
||||
widget: Pick<DashboardWidget, 'x' | 'y' | 'w' | 'h'>,
|
||||
metrics: GridCellMetrics,
|
||||
margins: DashboardResolvedMargins,
|
||||
) {
|
||||
const cellWidth = metrics.cellWidthPx;
|
||||
const cellHeight = metrics.cellHeightPx;
|
||||
const left = widget.x * (cellWidth + margins.horizontal) + margins.horizontal;
|
||||
const top = widget.y * (cellHeight + margins.vertical) + margins.vertical;
|
||||
const width = widget.w * cellWidth + Math.max(0, widget.w - 1) * margins.horizontal;
|
||||
const height = widget.h * cellHeight + Math.max(0, widget.h - 1) * margins.vertical;
|
||||
|
||||
return { left, top, width, height };
|
||||
}
|
||||
}
|
2
ts_web/elements/dees-dashboardgrid/index.ts
Normal file
2
ts_web/elements/dees-dashboardgrid/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './dees-dashboardgrid.js';
|
||||
export * from './types.js';
|
105
ts_web/elements/dees-dashboardgrid/interaction.ts
Normal file
105
ts_web/elements/dees-dashboardgrid/interaction.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { DashboardWidget, GridCellMetrics } from './types.js';
|
||||
|
||||
export interface PointerPosition {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}
|
||||
|
||||
export interface DragComputationArgs {
|
||||
pointer: PointerPosition;
|
||||
containerRect: DOMRect;
|
||||
metrics: GridCellMetrics;
|
||||
columns: number;
|
||||
widget: DashboardWidget;
|
||||
rtl: boolean;
|
||||
dragOffsetX?: number;
|
||||
dragOffsetY?: number;
|
||||
}
|
||||
|
||||
export const computeGridCoordinates = ({
|
||||
pointer,
|
||||
containerRect,
|
||||
metrics,
|
||||
columns,
|
||||
widget,
|
||||
rtl,
|
||||
dragOffsetX = 0,
|
||||
dragOffsetY = 0,
|
||||
}: DragComputationArgs): { x: number; y: number } => {
|
||||
const relativeX = pointer.clientX - containerRect.left - dragOffsetX;
|
||||
const relativeY = pointer.clientY - containerRect.top - dragOffsetY;
|
||||
|
||||
const marginX = metrics.marginHorizontalPx;
|
||||
const marginY = metrics.marginVerticalPx;
|
||||
const cellWidth = metrics.cellWidthPx;
|
||||
const cellHeight = metrics.cellHeightPx;
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
|
||||
|
||||
const adjustedX = clamp(relativeX - marginX, 0, containerRect.width - marginX);
|
||||
const adjustedY = clamp(relativeY - marginY, 0, Number.POSITIVE_INFINITY);
|
||||
|
||||
const cellPlusMarginX = cellWidth + marginX;
|
||||
const cellPlusMarginY = cellHeight + marginY;
|
||||
|
||||
let gridX = Math.round(adjustedX / cellPlusMarginX);
|
||||
if (rtl) {
|
||||
gridX = columns - widget.w - gridX;
|
||||
}
|
||||
gridX = clamp(gridX, 0, columns - widget.w);
|
||||
|
||||
const gridY = clamp(Math.round(adjustedY / cellPlusMarginY), 0, Number.MAX_SAFE_INTEGER);
|
||||
|
||||
return { x: gridX, y: gridY };
|
||||
};
|
||||
|
||||
export interface ResizeComputationArgs {
|
||||
pointer: PointerPosition;
|
||||
containerRect: DOMRect;
|
||||
metrics: GridCellMetrics;
|
||||
startWidth: number;
|
||||
startHeight: number;
|
||||
startPointer: PointerPosition;
|
||||
handler: 'e' | 's' | 'se';
|
||||
widget: DashboardWidget;
|
||||
columns: number;
|
||||
}
|
||||
|
||||
export const computeResizeDimensions = ({
|
||||
pointer,
|
||||
containerRect,
|
||||
metrics,
|
||||
startWidth,
|
||||
startHeight,
|
||||
startPointer,
|
||||
handler,
|
||||
widget,
|
||||
columns,
|
||||
}: ResizeComputationArgs): { width: number; height: number } => {
|
||||
const deltaX = pointer.clientX - startPointer.clientX;
|
||||
const deltaY = pointer.clientY - startPointer.clientY;
|
||||
|
||||
let width = startWidth;
|
||||
let height = startHeight;
|
||||
|
||||
const cellPlusMarginX = metrics.cellWidthPx + metrics.marginHorizontalPx;
|
||||
const cellPlusMarginY = metrics.cellHeightPx + metrics.marginVerticalPx;
|
||||
|
||||
if (handler.includes('e')) {
|
||||
const deltaCols = Math.round(deltaX / cellPlusMarginX);
|
||||
width = startWidth + deltaCols;
|
||||
}
|
||||
|
||||
if (handler.includes('s')) {
|
||||
const deltaRows = Math.round(deltaY / cellPlusMarginY);
|
||||
height = startHeight + deltaRows;
|
||||
}
|
||||
|
||||
const clampedWidth = Math.max(widget.minW || 1, Math.min(width, widget.maxW || columns - widget.x));
|
||||
const clampedHeight = Math.max(widget.minH || 1, Math.min(height, widget.maxH || Number.MAX_SAFE_INTEGER));
|
||||
|
||||
return {
|
||||
width: clampedWidth,
|
||||
height: clampedHeight,
|
||||
};
|
||||
};
|
246
ts_web/elements/dees-dashboardgrid/layout.ts
Normal file
246
ts_web/elements/dees-dashboardgrid/layout.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import type {
|
||||
DashboardResolvedMargins,
|
||||
DashboardMargin,
|
||||
DashboardWidget,
|
||||
DashboardLayoutItem,
|
||||
GridCellMetrics,
|
||||
LayoutDirection,
|
||||
} from './types.js';
|
||||
|
||||
export const DEFAULT_MARGIN = 10;
|
||||
|
||||
export const resolveMargins = (margin: DashboardMargin): DashboardResolvedMargins => {
|
||||
if (typeof margin === 'number') {
|
||||
return {
|
||||
horizontal: margin,
|
||||
vertical: margin,
|
||||
top: margin,
|
||||
right: margin,
|
||||
bottom: margin,
|
||||
left: margin,
|
||||
};
|
||||
}
|
||||
|
||||
const resolved = {
|
||||
top: margin.top ?? DEFAULT_MARGIN,
|
||||
right: margin.right ?? DEFAULT_MARGIN,
|
||||
bottom: margin.bottom ?? DEFAULT_MARGIN,
|
||||
left: margin.left ?? DEFAULT_MARGIN,
|
||||
};
|
||||
|
||||
return {
|
||||
...resolved,
|
||||
horizontal: (resolved.left + resolved.right) / 2,
|
||||
vertical: (resolved.top + resolved.bottom) / 2,
|
||||
};
|
||||
};
|
||||
|
||||
export const calculateCellMetrics = (
|
||||
containerWidth: number,
|
||||
columns: number,
|
||||
margins: DashboardResolvedMargins,
|
||||
cellHeight: number,
|
||||
cellHeightUnit: string,
|
||||
): GridCellMetrics => {
|
||||
const totalMarginWidth = margins.horizontal * (columns + 1);
|
||||
const availableWidth = Math.max(containerWidth - totalMarginWidth, 0);
|
||||
const cellWidthPx = columns > 0 ? availableWidth / columns : 0;
|
||||
const cellHeightPx = cellHeightUnit === 'auto' ? cellWidthPx : cellHeight;
|
||||
|
||||
return {
|
||||
containerWidth,
|
||||
cellWidthPx,
|
||||
marginHorizontalPx: margins.horizontal,
|
||||
cellHeightPx,
|
||||
marginVerticalPx: margins.vertical,
|
||||
};
|
||||
};
|
||||
|
||||
export const calculateGridHeight = (
|
||||
widgets: DashboardWidget[],
|
||||
margins: DashboardResolvedMargins,
|
||||
cellHeight: number,
|
||||
): number => {
|
||||
if (widgets.length === 0) return 0;
|
||||
const maxY = Math.max(...widgets.map(widget => widget.y + widget.h), 0);
|
||||
return maxY * cellHeight + (maxY + 1) * margins.vertical;
|
||||
};
|
||||
|
||||
const overlaps = (
|
||||
widget: DashboardWidget,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
) => x < widget.x + widget.w && x + w > widget.x && y < widget.y + widget.h && y + h > widget.y;
|
||||
|
||||
export const collectCollisions = (
|
||||
widgets: DashboardWidget[],
|
||||
target: DashboardWidget,
|
||||
nextX: number,
|
||||
nextY: number,
|
||||
nextW: number = target.w,
|
||||
nextH: number = target.h,
|
||||
): DashboardWidget[] => {
|
||||
return widgets.filter(widget => {
|
||||
if (widget.id === target.id) return false;
|
||||
return overlaps(widget, nextX, nextY, nextW, nextH);
|
||||
});
|
||||
};
|
||||
|
||||
export const checkCollision = (
|
||||
widgets: DashboardWidget[],
|
||||
target: DashboardWidget,
|
||||
nextX: number,
|
||||
nextY: number,
|
||||
): boolean => collectCollisions(widgets, target, nextX, nextY).length > 0;
|
||||
|
||||
export const cloneWidget = (widget: DashboardWidget): DashboardWidget => ({ ...widget });
|
||||
|
||||
export const cloneWidgets = (widgets: DashboardWidget[]): DashboardWidget[] => widgets.map(cloneWidget);
|
||||
|
||||
export const findAvailablePosition = (
|
||||
widgets: DashboardWidget[],
|
||||
width: number,
|
||||
height: number,
|
||||
columns: number,
|
||||
): { x: number; y: number } => {
|
||||
for (let y = 0; y < 200; y++) {
|
||||
for (let x = 0; x <= columns - width; x++) {
|
||||
const isFree = !widgets.some(widget => overlaps(widget, x, y, width, height));
|
||||
if (isFree) {
|
||||
return { x, y };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const maxY = widgets.reduce((acc, widget) => Math.max(acc, widget.y + widget.h), 0);
|
||||
return { x: 0, y: maxY };
|
||||
};
|
||||
|
||||
export interface PlacementResult {
|
||||
widgets: DashboardWidget[];
|
||||
movedWidgets: string[];
|
||||
swappedWith?: string;
|
||||
}
|
||||
|
||||
export const resolveWidgetPlacement = (
|
||||
widgets: DashboardWidget[],
|
||||
widgetId: string,
|
||||
next: { x: number; y: number; w?: number; h?: number },
|
||||
columns: number,
|
||||
previousPosition?: DashboardLayoutItem,
|
||||
): PlacementResult | null => {
|
||||
const sourceWidgets = cloneWidgets(widgets);
|
||||
const moving = sourceWidgets.find(widget => widget.id === widgetId);
|
||||
const original = widgets.find(widget => widget.id === widgetId);
|
||||
if (!moving || !original) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = {
|
||||
x: next.x,
|
||||
y: next.y,
|
||||
w: next.w ?? moving.w,
|
||||
h: next.h ?? moving.h,
|
||||
};
|
||||
|
||||
moving.x = target.x;
|
||||
moving.y = target.y;
|
||||
moving.w = target.w;
|
||||
moving.h = target.h;
|
||||
|
||||
const collisions = collectCollisions(sourceWidgets, moving, target.x, target.y, target.w, target.h);
|
||||
|
||||
if (collisions.length === 0) {
|
||||
return { widgets: sourceWidgets, movedWidgets: [moving.id] };
|
||||
}
|
||||
|
||||
if (collisions.length === 1) {
|
||||
const other = collisions[0];
|
||||
if (!other.locked && !other.noMove && other.w === moving.w && other.h === moving.h) {
|
||||
const otherClone = sourceWidgets.find(widget => widget.id === other.id);
|
||||
if (otherClone) {
|
||||
// Use the original position of the moving widget for a clean swap
|
||||
// This prevents the "snapping together" issue where both widgets end up at the same position
|
||||
const swapTarget = original;
|
||||
const previousOtherPosition = { x: otherClone.x, y: otherClone.y };
|
||||
otherClone.x = swapTarget.x;
|
||||
otherClone.y = swapTarget.y;
|
||||
|
||||
const swapValid =
|
||||
collectCollisions(sourceWidgets, moving, moving.x, moving.y, moving.w, moving.h).length === 0 &&
|
||||
collectCollisions(sourceWidgets, otherClone, otherClone.x, otherClone.y, otherClone.w, otherClone.h).length === 0;
|
||||
|
||||
if (swapValid) {
|
||||
return { widgets: sourceWidgets, movedWidgets: [moving.id, otherClone.id], swappedWith: otherClone.id };
|
||||
}
|
||||
|
||||
otherClone.x = previousOtherPosition.x;
|
||||
otherClone.y = previousOtherPosition.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// attempt displacement cascade
|
||||
const movedIds = new Set<string>([moving.id]);
|
||||
for (const offending of collisions) {
|
||||
if (offending.locked || offending.noMove) {
|
||||
return null;
|
||||
}
|
||||
const clone = sourceWidgets.find(widget => widget.id === offending.id);
|
||||
if (!clone) continue;
|
||||
const remaining = sourceWidgets.filter(widget => widget.id !== offending.id);
|
||||
const position = findAvailablePosition(remaining, clone.w, clone.h, columns);
|
||||
clone.x = position.x;
|
||||
clone.y = position.y;
|
||||
movedIds.add(clone.id);
|
||||
}
|
||||
|
||||
// verify no overlaps remain
|
||||
const verify = collectCollisions(sourceWidgets, moving, moving.x, moving.y, moving.w, moving.h);
|
||||
if (verify.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { widgets: sourceWidgets, movedWidgets: Array.from(movedIds) };
|
||||
};
|
||||
|
||||
export const compactLayout = (
|
||||
widgets: DashboardWidget[],
|
||||
direction: LayoutDirection = 'vertical',
|
||||
) => {
|
||||
const sorted = [...widgets].sort((a, b) => {
|
||||
if (direction === 'vertical') {
|
||||
if (a.y !== b.y) return a.y - b.y;
|
||||
return a.x - b.x;
|
||||
}
|
||||
|
||||
if (a.x !== b.x) return a.x - b.x;
|
||||
return a.y - b.y;
|
||||
});
|
||||
|
||||
for (const widget of sorted) {
|
||||
if (widget.locked || widget.noMove) continue;
|
||||
|
||||
if (direction === 'vertical') {
|
||||
while (widget.y > 0 && !checkCollision(widgets, widget, widget.x, widget.y - 1)) {
|
||||
widget.y -= 1;
|
||||
}
|
||||
} else {
|
||||
while (widget.x > 0 && !checkCollision(widgets, widget, widget.x - 1, widget.y)) {
|
||||
widget.x -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const applyLayout = (
|
||||
widgets: DashboardWidget[],
|
||||
layout: DashboardLayoutItem[],
|
||||
): DashboardWidget[] => {
|
||||
return widgets.map(widget => {
|
||||
const layoutItem = layout.find(item => item.id === widget.id);
|
||||
return layoutItem ? { ...widget, ...layoutItem } : widget;
|
||||
});
|
||||
};
|
249
ts_web/elements/dees-dashboardgrid/styles.ts
Normal file
249
ts_web/elements/dees-dashboardgrid/styles.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { css, cssManager } from '@design.estate/dees-element';
|
||||
|
||||
export const dashboardGridStyles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 400px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.grid-widget {
|
||||
position: absolute;
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
:host([enableanimation]) .grid-widget {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.grid-widget.dragging {
|
||||
z-index: 1000;
|
||||
transition: none !important;
|
||||
opacity: 0.8;
|
||||
cursor: grabbing;
|
||||
pointer-events: none;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.grid-widget.placeholder {
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.grid-widget.placeholder .widget-content {
|
||||
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.1)')};
|
||||
border: 2px dashed ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.grid-widget.resizing {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.widget-content {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#09090b')};
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
border-radius: 8px;
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
'0 1px 3px rgba(0, 0, 0, 0.3)'
|
||||
)};
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.grid-widget:hover .widget-content {
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
'0 4px 12px rgba(0, 0, 0, 0.4)'
|
||||
)};
|
||||
}
|
||||
|
||||
.grid-widget.dragging .widget-content {
|
||||
box-shadow: ${cssManager.bdTheme(
|
||||
'0 16px 48px rgba(0, 0, 0, 0.25)',
|
||||
'0 16px 48px rgba(0, 0, 0, 0.6)'
|
||||
)};
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.widget-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')};
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.widget-header:hover {
|
||||
background: ${cssManager.bdTheme('#f4f4f5', '#18181b')};
|
||||
}
|
||||
|
||||
.widget-header:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.widget-header.locked {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.widget-header.locked:hover {
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#0a0a0a')};
|
||||
}
|
||||
|
||||
.widget-header dees-icon {
|
||||
font-size: 16px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
}
|
||||
|
||||
.widget-body {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: auto;
|
||||
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
||||
}
|
||||
|
||||
.widget-body.has-header {
|
||||
top: 45px;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
background: transparent;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.resize-handle:hover {
|
||||
background: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.resize-handle-e {
|
||||
cursor: ew-resize;
|
||||
width: 12px;
|
||||
right: -6px;
|
||||
top: 10%;
|
||||
height: 80%;
|
||||
}
|
||||
|
||||
.resize-handle-s {
|
||||
cursor: ns-resize;
|
||||
height: 12px;
|
||||
width: 80%;
|
||||
bottom: -6px;
|
||||
left: 10%;
|
||||
}
|
||||
|
||||
.resize-handle-se {
|
||||
cursor: se-resize;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.resize-handle-se::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
bottom: 4px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-right: 2px solid ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
border-bottom: 2px solid ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
}
|
||||
|
||||
.grid-widget:hover .resize-handle-se {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.resize-handle-se:hover {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.resize-handle-se:hover::after {
|
||||
border-color: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
}
|
||||
|
||||
.grid-placeholder {
|
||||
position: absolute;
|
||||
background: ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
opacity: 0.1;
|
||||
border-radius: 8px;
|
||||
border: 2px dashed ${cssManager.bdTheme('#3b82f6', '#3b82f6')};
|
||||
transition: all 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400px;
|
||||
color: ${cssManager.bdTheme('#71717a', '#71717a')};
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.empty-state dees-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.grid-lines {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.grid-line-vertical {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.grid-line-horizontal {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
||||
opacity: 0.3;
|
||||
}
|
||||
`,
|
||||
];
|
53
ts_web/elements/dees-dashboardgrid/types.ts
Normal file
53
ts_web/elements/dees-dashboardgrid/types.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { TemplateResult } from '@design.estate/dees-element';
|
||||
|
||||
export type CellHeightUnit = 'px' | 'em' | 'rem' | 'auto';
|
||||
|
||||
export interface DashboardMarginObject {
|
||||
top?: number;
|
||||
right?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
}
|
||||
|
||||
export type DashboardMargin = number | DashboardMarginObject;
|
||||
|
||||
export interface DashboardResolvedMargins {
|
||||
horizontal: number;
|
||||
vertical: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
export interface DashboardLayoutItem {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface DashboardWidget extends DashboardLayoutItem {
|
||||
minW?: number;
|
||||
minH?: number;
|
||||
maxW?: number;
|
||||
maxH?: number;
|
||||
content: TemplateResult | string;
|
||||
title?: string;
|
||||
icon?: string;
|
||||
noMove?: boolean;
|
||||
noResize?: boolean;
|
||||
locked?: boolean;
|
||||
autoPosition?: boolean;
|
||||
}
|
||||
|
||||
export type LayoutDirection = 'vertical' | 'horizontal';
|
||||
|
||||
export interface GridCellMetrics {
|
||||
containerWidth: number;
|
||||
cellWidthPx: number;
|
||||
marginHorizontalPx: number;
|
||||
cellHeightPx: number;
|
||||
marginVerticalPx: number;
|
||||
}
|
@@ -8,6 +8,7 @@ import {
|
||||
cssManager,
|
||||
} from '@design.estate/dees-element';
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
import { MONACO_VERSION } from './version.js';
|
||||
|
||||
import type * as monaco from 'monaco-editor';
|
||||
|
||||
@@ -80,10 +81,11 @@ export class DeesEditor extends DeesElement {
|
||||
): Promise<void> {
|
||||
super.firstUpdated(_changedProperties);
|
||||
const container = this.shadowRoot.getElementById('container');
|
||||
const monacoCdnBase = `https://cdn.jsdelivr.net/npm/monaco-editor@${MONACO_VERSION}`;
|
||||
|
||||
if (!DeesEditor.monacoDeferred) {
|
||||
DeesEditor.monacoDeferred = domtools.plugins.smartpromise.defer();
|
||||
const scriptUrl = `https://cdn.jsdelivr.net/npm/monaco-editor/min/vs/loader.js`;
|
||||
const scriptUrl = `${monacoCdnBase}/min/vs/loader.js`;
|
||||
const script = document.createElement('script');
|
||||
script.src = scriptUrl;
|
||||
script.onload = () => {
|
||||
@@ -94,7 +96,7 @@ export class DeesEditor extends DeesElement {
|
||||
await DeesEditor.monacoDeferred.promise;
|
||||
|
||||
(window as any).require.config({
|
||||
paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor/min/vs' },
|
||||
paths: { vs: `${monacoCdnBase}/min/vs` },
|
||||
});
|
||||
(window as any).require(['vs/editor/editor.main'], async () => {
|
||||
const editor = ((window as any).monaco.editor as typeof monaco.editor).create(container, {
|
||||
@@ -109,7 +111,7 @@ export class DeesEditor extends DeesElement {
|
||||
this.editorDeferred.resolve(editor);
|
||||
});
|
||||
const css = await (
|
||||
await fetch('https://cdn.jsdelivr.net/npm/monaco-editor/min/vs/editor/editor.main.css')
|
||||
await fetch(`${monacoCdnBase}/min/vs/editor/editor.main.css`)
|
||||
).text();
|
||||
const styleElement = document.createElement('style');
|
||||
styleElement.textContent = css;
|
2
ts_web/elements/dees-editor/version.ts
Normal file
2
ts_web/elements/dees-editor/version.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Auto-generated by scripts/update-monaco-version.cjs
|
||||
export const MONACO_VERSION = '0.52.2';
|
@@ -20,7 +20,7 @@ import { DeesInputMultitoggle } from './dees-input-multitoggle.js';
|
||||
import { DeesInputPhone } from './dees-input-phone.js';
|
||||
import { DeesInputTypelist } from './dees-input-typelist.js';
|
||||
import { DeesFormSubmit } from './dees-form-submit.js';
|
||||
import { DeesTable } from './dees-table.js';
|
||||
import { DeesTable } from './dees-table/dees-table.js';
|
||||
import { demoFunc } from './dees-form.demo.js';
|
||||
|
||||
// Unified set for form input types
|
||||
|
@@ -28,6 +28,9 @@ export class DeesInputCheckbox extends DeesInputBase<DeesInputCheckbox> {
|
||||
})
|
||||
public value: boolean = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
public indeterminate: boolean = false;
|
||||
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -166,6 +169,14 @@ export class DeesInputCheckbox extends DeesInputBase<DeesInputCheckbox> {
|
||||
</svg>
|
||||
</span>
|
||||
`
|
||||
: this.indeterminate
|
||||
? html`
|
||||
<span class="checkmark">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 12H19" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
`
|
||||
: html``}
|
||||
</div>
|
||||
<div class="label-container">
|
||||
|
@@ -36,30 +36,130 @@ export class DeesStepper extends DeesElement {
|
||||
<dees-stepper
|
||||
.steps=${[
|
||||
{
|
||||
title: 'Whats your name?',
|
||||
title: 'Account Setup',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text
|
||||
key="email"
|
||||
label="Your Email"
|
||||
value="hello@something.com"
|
||||
disabled
|
||||
></dees-input-text>
|
||||
<dees-input-text key="firstName" required label="Vorname"></dees-input-text>
|
||||
<dees-input-text key="lastName" required label="Nachname"></dees-input-text>
|
||||
<dees-form-submit>Next</dees-form-submit>
|
||||
<dees-input-text key="email" label="Work Email" required></dees-input-text>
|
||||
<dees-input-text key="password" label="Create Password" type="password" required></dees-input-text>
|
||||
<dees-form-submit>Continue</dees-form-submit>
|
||||
</dees-form>
|
||||
`,
|
||||
validationFunc: async (stepperArg, elementArg) => {
|
||||
const deesForm = elementArg.querySelector('dees-form');
|
||||
deesForm.addEventListener('formData', (eventArg) => {
|
||||
stepperArg.goNext();
|
||||
});
|
||||
deesForm.addEventListener('formData', () => stepperArg.goNext(), { once: true });
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Whats your mobile number?',
|
||||
content: html``,
|
||||
title: 'Profile Details',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text key="firstName" label="First Name" required></dees-input-text>
|
||||
<dees-input-text key="lastName" label="Last Name" required></dees-input-text>
|
||||
<dees-form-submit>Continue</dees-form-submit>
|
||||
</dees-form>
|
||||
`,
|
||||
validationFunc: async (stepperArg, elementArg) => {
|
||||
const deesForm = elementArg.querySelector('dees-form');
|
||||
deesForm.addEventListener('formData', () => stepperArg.goNext(), { once: true });
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Contact Information',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-phone key="phone" label="Mobile Number" required></dees-input-phone>
|
||||
<dees-input-text key="company" label="Company"></dees-input-text>
|
||||
<dees-form-submit>Continue</dees-form-submit>
|
||||
</dees-form>
|
||||
`,
|
||||
validationFunc: async (stepperArg, elementArg) => {
|
||||
const deesForm = elementArg.querySelector('dees-form');
|
||||
deesForm.addEventListener('formData', () => stepperArg.goNext(), { once: true });
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Team Size',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-dropdown
|
||||
key="teamSize"
|
||||
label="How big is your team?"
|
||||
.options=${[
|
||||
{ label: '1-5', value: '1-5' },
|
||||
{ label: '6-20', value: '6-20' },
|
||||
{ label: '21-50', value: '21-50' },
|
||||
{ label: '51+', value: '51+' },
|
||||
]}
|
||||
required
|
||||
></dees-input-dropdown>
|
||||
<dees-form-submit>Continue</dees-form-submit>
|
||||
</dees-form>
|
||||
`,
|
||||
validationFunc: async (stepperArg, elementArg) => {
|
||||
const deesForm = elementArg.querySelector('dees-form');
|
||||
deesForm.addEventListener('formData', () => stepperArg.goNext(), { once: true });
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Goals',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-multitoggle
|
||||
key="goal"
|
||||
label="Main objective"
|
||||
.options=${[
|
||||
{ label: 'Onboarding', value: 'onboarding' },
|
||||
{ label: 'Analytics', value: 'analytics' },
|
||||
{ label: 'Automation', value: 'automation' },
|
||||
]}
|
||||
required
|
||||
></dees-input-multitoggle>
|
||||
<dees-form-submit>Continue</dees-form-submit>
|
||||
</dees-form>
|
||||
`,
|
||||
validationFunc: async (stepperArg, elementArg) => {
|
||||
const deesForm = elementArg.querySelector('dees-form');
|
||||
deesForm.addEventListener('formData', () => stepperArg.goNext(), { once: true });
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Brand Preferences',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text key="brandColor" label="Primary brand color"></dees-input-text>
|
||||
<dees-input-text key="tone" label="Preferred tone (e.g. friendly, formal)"></dees-input-text>
|
||||
<dees-form-submit>Continue</dees-form-submit>
|
||||
</dees-form>
|
||||
`,
|
||||
validationFunc: async (stepperArg, elementArg) => {
|
||||
const deesForm = elementArg.querySelector('dees-form');
|
||||
deesForm.addEventListener('formData', () => stepperArg.goNext(), { once: true });
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Integrations',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-list
|
||||
key="integrations"
|
||||
label="Integrations in use"
|
||||
placeholder="Add integration"
|
||||
></dees-input-list>
|
||||
<dees-form-submit>Continue</dees-form-submit>
|
||||
</dees-form>
|
||||
`,
|
||||
validationFunc: async (stepperArg, elementArg) => {
|
||||
const deesForm = elementArg.querySelector('dees-form');
|
||||
deesForm.addEventListener('formData', () => stepperArg.goNext(), { once: true });
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Review & Launch',
|
||||
content: html`
|
||||
<dees-panel>
|
||||
<p>Almost there! Review your selections and launch whenever you're ready.</p>
|
||||
</dees-panel>
|
||||
`,
|
||||
},
|
||||
] as IStep[]}
|
||||
></dees-stepper>
|
||||
@@ -99,30 +199,33 @@ export class DeesStepper extends DeesElement {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
transition: all 0.7s ease-in-out;
|
||||
transition: transform 0.35s ease, box-shadow 0.35s ease, filter 0.35s ease, border 0.35s ease;
|
||||
max-width: 500px;
|
||||
min-height: 300px;
|
||||
border-radius: 16px;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#181818')};
|
||||
border-top: 1px solid ${cssManager.bdTheme('#ffffff', '#181818')};
|
||||
color: ${cssManager.bdTheme('#333', '#fff')};
|
||||
border-radius: 18px;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#18181b')};
|
||||
border: 1px solid ${cssManager.bdTheme('rgba(226, 232, 240, 0.9)', 'rgba(63, 63, 70, 0.85)')};
|
||||
color: ${cssManager.bdTheme('#0f172a', '#f5f5f5')};
|
||||
margin: auto;
|
||||
margin-bottom: 20px;
|
||||
filter: opacity(0.5) grayscale(1);
|
||||
box-shadow: 0px 0px 3px #00000010;
|
||||
filter: opacity(0.55) saturate(0.85);
|
||||
box-shadow: ${cssManager.bdTheme('0 20px 40px -25px rgba(15, 23, 42, 0.45)', '0 20px 36px -22px rgba(15, 23, 42, 0.65)')};
|
||||
user-select: none;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.step.selected {
|
||||
border-top: 1px solid #e4002b;
|
||||
pointer-events: all;
|
||||
filter: opacity(1) grayscale(0);
|
||||
box-shadow: 0px 0px 5px #00000010;
|
||||
filter: opacity(1) saturate(1);
|
||||
transform: translateY(-6px);
|
||||
border: 1px solid ${cssManager.bdTheme(colors.dark.blue, colors.dark.blue)};
|
||||
box-shadow: ${cssManager.bdTheme('0 28px 60px -30px rgba(15, 23, 42, 0.42)', '0 26px 55px -28px rgba(37, 99, 235, 0.6)')};
|
||||
user-select: auto;
|
||||
}
|
||||
|
||||
.step.hiddenStep {
|
||||
filter: opacity(0);
|
||||
transform: translateY(16px);
|
||||
}
|
||||
|
||||
.step:last-child {
|
||||
@@ -130,40 +233,50 @@ export class DeesStepper extends DeesElement {
|
||||
}
|
||||
|
||||
.step .stepCounter {
|
||||
color: #999;
|
||||
color: ${cssManager.bdTheme('#64748b', '#a1a1aa')};
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
padding: 10px 15px;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
border-bottom-left-radius: 3px;
|
||||
background: ${cssManager.bdTheme('#00000008', '#ffffff08')};
|
||||
border-radius: 999px;
|
||||
background: ${cssManager.bdTheme('rgba(226, 232, 240, 0.5)', 'rgba(63, 63, 70, 0.45)')};
|
||||
border: 1px solid ${cssManager.bdTheme('rgba(226, 232, 240, 0.7)', 'rgba(63, 63, 70, 0.6)')};
|
||||
}
|
||||
|
||||
.step .goBack {
|
||||
color: #999;
|
||||
cursor: default;
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
padding: 10px 15px;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
border-bottom-right-radius: 3px;
|
||||
background: ${cssManager.bdTheme('#00000008', '#ffffff08')};
|
||||
font-weight: 500;
|
||||
border-radius: 999px;
|
||||
border: 1px solid ${cssManager.bdTheme('rgba(226, 232, 240, 0.9)', 'rgba(63, 63, 70, 0.85)')};
|
||||
background: ${cssManager.bdTheme('rgba(255, 255, 255, 0.9)', 'rgba(39, 39, 42, 0.85)')};
|
||||
color: ${cssManager.bdTheme('#475569', '#d4d4d8')};
|
||||
cursor: pointer;
|
||||
transition: border 0.2s ease, color 0.2s ease, background 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.step .goBack:hover {
|
||||
color: ${cssManager.bdTheme('#333', '#fff')};
|
||||
background: ${cssManager.bdTheme('#00000012', colors.dark.blue)};
|
||||
color: ${cssManager.bdTheme('#0f172a', '#fafafa')};
|
||||
border-color: ${cssManager.bdTheme(colors.dark.blue, colors.dark.blue)};
|
||||
background: ${cssManager.bdTheme('rgba(226, 232, 240, 0.95)', 'rgba(63, 63, 70, 0.7)')};
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
|
||||
.step .goBack:active {
|
||||
color: ${cssManager.bdTheme('#333', '#fff')};
|
||||
background: ${cssManager.bdTheme('#00000012', colors.dark.blueActive)};
|
||||
color: ${cssManager.bdTheme('#0f172a', '#fafafa')};
|
||||
border-color: ${cssManager.bdTheme(colors.dark.blueActive, colors.dark.blueActive)};
|
||||
background: ${cssManager.bdTheme('rgba(226, 232, 240, 0.85)', 'rgba(63, 63, 70, 0.6)')};
|
||||
}
|
||||
|
||||
.step .goBack span {
|
||||
transition: all 0.2s;
|
||||
transition: transform 0.2s ease;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@@ -173,15 +286,16 @@ export class DeesStepper extends DeesElement {
|
||||
|
||||
.step .title {
|
||||
text-align: center;
|
||||
padding-top: 50px;
|
||||
padding-top: 64px;
|
||||
font-family: 'Geist Sans', sans-serif;
|
||||
font-size: 22px;
|
||||
|
||||
font-weight: 500;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.step .content {
|
||||
padding: 20px;
|
||||
padding: 24px 28px 32px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
@@ -270,7 +384,14 @@ export class DeesStepper extends DeesElement {
|
||||
|
||||
public async goBack() {
|
||||
const currentIndex = this.steps.findIndex((stepArg) => stepArg === this.selectedStep);
|
||||
this.selectedStep = this.steps[currentIndex - 1];
|
||||
if (currentIndex <= 0) {
|
||||
return;
|
||||
}
|
||||
const currentStep = this.steps[currentIndex];
|
||||
currentStep.validationFuncCalled = false;
|
||||
const previousStep = this.steps[currentIndex - 1];
|
||||
previousStep.validationFuncCalled = false;
|
||||
this.selectedStep = previousStep;
|
||||
await this.domtoolsPromise;
|
||||
await this.domtools.convenience.smartdelay.delayFor(100);
|
||||
this.selectedStep.onReturnToStepFunc?.(this, this.shadowRoot.querySelector('.selected'));
|
||||
@@ -278,6 +399,13 @@ export class DeesStepper extends DeesElement {
|
||||
|
||||
public goNext() {
|
||||
const currentIndex = this.steps.findIndex((stepArg) => stepArg === this.selectedStep);
|
||||
this.selectedStep = this.steps[currentIndex + 1];
|
||||
if (currentIndex < 0 || currentIndex >= this.steps.length - 1) {
|
||||
return;
|
||||
}
|
||||
const currentStep = this.steps[currentIndex];
|
||||
currentStep.validationFuncCalled = false;
|
||||
const nextStep = this.steps[currentIndex + 1];
|
||||
nextStep.validationFuncCalled = false;
|
||||
this.selectedStep = nextStep;
|
||||
}
|
||||
}
|
||||
|
@@ -1,432 +0,0 @@
|
||||
import { type ITableAction } from './dees-table.js';
|
||||
import * as plugins from './00plugins.js';
|
||||
import { html, css, cssManager } from '@design.estate/dees-element';
|
||||
|
||||
interface ITableDemoData {
|
||||
date: string;
|
||||
amount: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<style>
|
||||
${css`
|
||||
.demoWrapper {
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 32px;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95%)', 'hsl(0 0% 5%)')};
|
||||
overflow-y: auto;
|
||||
}
|
||||
.demo-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.demo-section {
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
.demo-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||
}
|
||||
.demo-description {
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.theme-toggle {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 1000;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div class="demoWrapper">
|
||||
<dees-button class="theme-toggle" @click=${() => {
|
||||
document.body.classList.toggle('bright');
|
||||
document.body.classList.toggle('dark');
|
||||
}}>Toggle Theme</dees-button>
|
||||
|
||||
<div class="demo-container">
|
||||
<div class="demo-section">
|
||||
<h2 class="demo-title">Basic Table with Actions</h2>
|
||||
<p class="demo-description">A standard table with row actions, editable fields, and context menu support. Double-click on descriptions to edit. Grid lines are enabled by default.</p>
|
||||
<dees-table
|
||||
heading1="Current Account Statement"
|
||||
heading2="Bunq - Payment Account 2 - April 2021"
|
||||
.editableFields="${['description']}"
|
||||
.data=${[
|
||||
{
|
||||
date: '2021-04-01',
|
||||
amount: '2464.65 €',
|
||||
description: 'Printing Paper (Office Supplies) - STAPLES BREMEN',
|
||||
},
|
||||
{
|
||||
date: '2021-04-02',
|
||||
amount: '165.65 €',
|
||||
description: 'Logitech Mouse (Hardware) - logi.com OnlineShop',
|
||||
},
|
||||
{
|
||||
date: '2021-04-03',
|
||||
amount: '2999,00 €',
|
||||
description: 'Macbook Pro 16inch (Hardware) - Apple.de OnlineShop',
|
||||
},
|
||||
{
|
||||
date: '2021-04-01',
|
||||
amount: '2464.65 €',
|
||||
description: 'Office-Supplies - STAPLES BREMEN',
|
||||
},
|
||||
{
|
||||
date: '2021-04-01',
|
||||
amount: '2464.65 €',
|
||||
description: 'Office-Supplies - STAPLES BREMEN',
|
||||
},
|
||||
]}
|
||||
dataName="transactions"
|
||||
.dataActions="${[
|
||||
{
|
||||
name: 'upload',
|
||||
iconName: 'bell',
|
||||
useTableBehaviour: 'upload',
|
||||
type: ['inRow'],
|
||||
actionFunc: async (optionsArg) => {
|
||||
alert(optionsArg.item.amount);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'visibility',
|
||||
iconName: 'copy',
|
||||
type: ['inRow'],
|
||||
useTableBehaviour: 'preview',
|
||||
actionFunc: async (itemArg: any) => {},
|
||||
},
|
||||
{
|
||||
name: 'create new',
|
||||
iconName: 'instagram',
|
||||
type: ['header'],
|
||||
useTableBehaviour: 'preview',
|
||||
actionFunc: async (itemArg: any) => {},
|
||||
},
|
||||
{
|
||||
name: 'to gallery',
|
||||
iconName: 'message',
|
||||
type: ['footer'],
|
||||
useTableBehaviour: 'preview',
|
||||
actionFunc: async (itemArg: any) => {},
|
||||
},
|
||||
{
|
||||
name: 'copy',
|
||||
iconName: 'copySolid',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
action: async () => {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'edit (from demo)',
|
||||
iconName: 'penToSquare',
|
||||
type: ['contextmenu'],
|
||||
action: async () => {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'paste',
|
||||
iconName: 'pasteSolid',
|
||||
type: ['contextmenu'],
|
||||
action: async () => {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'preview',
|
||||
type: ['doubleClick', 'contextmenu'],
|
||||
iconName: 'eye',
|
||||
actionFunc: async (itemArg) => {
|
||||
alert(itemArg.item.amount);
|
||||
return null;
|
||||
},
|
||||
}
|
||||
] as ITableAction[]}"
|
||||
></dees-table>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2 class="demo-title">Table with Vertical Lines</h2>
|
||||
<p class="demo-description">Enhanced column separation for better data tracking.</p>
|
||||
<dees-table
|
||||
heading1="Product Inventory"
|
||||
heading2="Current stock levels across warehouses"
|
||||
.showVerticalLines=${true}
|
||||
.data=${[
|
||||
{
|
||||
product: 'MacBook Pro 16"',
|
||||
warehouse_a: '45',
|
||||
warehouse_b: '32',
|
||||
warehouse_c: '28',
|
||||
total: '105',
|
||||
status: '✓ In Stock'
|
||||
},
|
||||
{
|
||||
product: 'iPhone 15 Pro',
|
||||
warehouse_a: '120',
|
||||
warehouse_b: '89',
|
||||
warehouse_c: '156',
|
||||
total: '365',
|
||||
status: '✓ In Stock'
|
||||
},
|
||||
{
|
||||
product: 'AirPods Pro',
|
||||
warehouse_a: '0',
|
||||
warehouse_b: '12',
|
||||
warehouse_c: '5',
|
||||
total: '17',
|
||||
status: '⚠ Low Stock'
|
||||
},
|
||||
{
|
||||
product: 'iPad Air',
|
||||
warehouse_a: '23',
|
||||
warehouse_b: '45',
|
||||
warehouse_c: '67',
|
||||
total: '135',
|
||||
status: '✓ In Stock'
|
||||
}
|
||||
]}
|
||||
dataName="products"
|
||||
></dees-table>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2 class="demo-title">Table with Full Grid</h2>
|
||||
<p class="demo-description">Complete grid lines for maximum readability and structure.</p>
|
||||
<dees-table
|
||||
heading1="Server Monitoring Dashboard"
|
||||
heading2="Real-time metrics across regions"
|
||||
.showGrid=${true}
|
||||
.data=${[
|
||||
{
|
||||
server: 'API-1',
|
||||
region: 'US-East',
|
||||
cpu: '45%',
|
||||
memory: '62%',
|
||||
disk: '78%',
|
||||
latency: '12ms',
|
||||
uptime: '99.9%',
|
||||
status: '🟢 Healthy'
|
||||
},
|
||||
{
|
||||
server: 'API-2',
|
||||
region: 'EU-West',
|
||||
cpu: '38%',
|
||||
memory: '55%',
|
||||
disk: '45%',
|
||||
latency: '25ms',
|
||||
uptime: '99.8%',
|
||||
status: '🟢 Healthy'
|
||||
},
|
||||
{
|
||||
server: 'DB-Master',
|
||||
region: 'US-East',
|
||||
cpu: '72%',
|
||||
memory: '81%',
|
||||
disk: '92%',
|
||||
latency: '8ms',
|
||||
uptime: '100%',
|
||||
status: '🟡 Warning'
|
||||
},
|
||||
{
|
||||
server: 'DB-Replica',
|
||||
region: 'EU-West',
|
||||
cpu: '23%',
|
||||
memory: '34%',
|
||||
disk: '45%',
|
||||
latency: '15ms',
|
||||
uptime: '99.7%',
|
||||
status: '🟢 Healthy'
|
||||
},
|
||||
{
|
||||
server: 'Cache-1',
|
||||
region: 'AP-South',
|
||||
cpu: '89%',
|
||||
memory: '92%',
|
||||
disk: '12%',
|
||||
latency: '120ms',
|
||||
uptime: '98.5%',
|
||||
status: '🔴 Critical'
|
||||
}
|
||||
]}
|
||||
dataName="servers"
|
||||
.dataActions="${[
|
||||
{
|
||||
name: 'SSH Connect',
|
||||
iconName: 'lucide:terminal',
|
||||
type: ['inRow'],
|
||||
actionFunc: async (optionsArg) => {
|
||||
console.log('Connecting to:', optionsArg.item.server);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'View Logs',
|
||||
iconName: 'lucide:file-text',
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async (optionsArg) => {
|
||||
console.log('Viewing logs for:', optionsArg.item.server);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Restart Server',
|
||||
iconName: 'lucide:refresh-cw',
|
||||
type: ['contextmenu'],
|
||||
actionFunc: async (optionsArg) => {
|
||||
console.log('Restarting:', optionsArg.item.server);
|
||||
},
|
||||
}
|
||||
] as ITableAction[]}"
|
||||
></dees-table>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2 class="demo-title">Table with Horizontal Lines Only</h2>
|
||||
<p class="demo-description">Emphasis on row separation without column dividers.</p>
|
||||
<dees-table
|
||||
heading1="Sales Performance"
|
||||
heading2="Top performers this quarter"
|
||||
.showHorizontalLines=${true}
|
||||
.showVerticalLines=${false}
|
||||
.data=${[
|
||||
{
|
||||
salesperson: 'Emily Johnson',
|
||||
region: 'North America',
|
||||
deals_closed: '42',
|
||||
revenue: '$1.2M',
|
||||
quota_achievement: '128%',
|
||||
rating: '⭐⭐⭐⭐⭐'
|
||||
},
|
||||
{
|
||||
salesperson: 'Michael Chen',
|
||||
region: 'Asia Pacific',
|
||||
deals_closed: '38',
|
||||
revenue: '$980K',
|
||||
quota_achievement: '115%',
|
||||
rating: '⭐⭐⭐⭐⭐'
|
||||
},
|
||||
{
|
||||
salesperson: 'Sarah Williams',
|
||||
region: 'Europe',
|
||||
deals_closed: '35',
|
||||
revenue: '$875K',
|
||||
quota_achievement: '108%',
|
||||
rating: '⭐⭐⭐⭐'
|
||||
},
|
||||
{
|
||||
salesperson: 'David Garcia',
|
||||
region: 'Latin America',
|
||||
deals_closed: '31',
|
||||
revenue: '$750K',
|
||||
quota_achievement: '95%',
|
||||
rating: '⭐⭐⭐⭐'
|
||||
}
|
||||
]}
|
||||
dataName="sales reps"
|
||||
></dees-table>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2 class="demo-title">Simple Table (No Grid)</h2>
|
||||
<p class="demo-description">Clean, minimal design without grid lines. Set showGrid to false to disable the default grid.</p>
|
||||
<dees-table
|
||||
heading1="Team Members"
|
||||
heading2="Engineering Department"
|
||||
.showGrid=${false}
|
||||
.data=${[
|
||||
{
|
||||
name: 'Alice Johnson',
|
||||
role: 'Lead Engineer',
|
||||
email: 'alice@company.com',
|
||||
location: 'San Francisco',
|
||||
joined: '2020-03-15'
|
||||
},
|
||||
{
|
||||
name: 'Bob Smith',
|
||||
role: 'Senior Developer',
|
||||
email: 'bob@company.com',
|
||||
location: 'New York',
|
||||
joined: '2019-07-22'
|
||||
},
|
||||
{
|
||||
name: 'Charlie Davis',
|
||||
role: 'DevOps Engineer',
|
||||
email: 'charlie@company.com',
|
||||
location: 'London',
|
||||
joined: '2021-01-10'
|
||||
},
|
||||
{
|
||||
name: 'Diana Martinez',
|
||||
role: 'Frontend Developer',
|
||||
email: 'diana@company.com',
|
||||
location: 'Barcelona',
|
||||
joined: '2022-05-18'
|
||||
}
|
||||
]}
|
||||
dataName="team members"
|
||||
></dees-table>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2 class="demo-title">Table with Custom Display Function</h2>
|
||||
<p class="demo-description">Transform data for display using custom formatting.</p>
|
||||
<dees-table
|
||||
heading1="Sales Report"
|
||||
heading2="Q4 2023 Performance"
|
||||
.data=${[
|
||||
{
|
||||
product: 'Enterprise License',
|
||||
units: 45,
|
||||
revenue: 225000,
|
||||
growth: 0.23,
|
||||
forecast: 280000
|
||||
},
|
||||
{
|
||||
product: 'Professional License',
|
||||
units: 128,
|
||||
revenue: 128000,
|
||||
growth: 0.15,
|
||||
forecast: 147000
|
||||
},
|
||||
{
|
||||
product: 'Starter License',
|
||||
units: 342,
|
||||
revenue: 68400,
|
||||
growth: 0.42,
|
||||
forecast: 97000
|
||||
}
|
||||
]}
|
||||
.displayFunction=${(item) => ({
|
||||
Product: item.product,
|
||||
'Units Sold': item.units.toLocaleString(),
|
||||
Revenue: '$' + item.revenue.toLocaleString(),
|
||||
Growth: (item.growth * 100).toFixed(1) + '%',
|
||||
'Q1 2024 Forecast': '$' + item.forecast.toLocaleString()
|
||||
})}
|
||||
dataName="products"
|
||||
></dees-table>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2 class="demo-title">Empty Table State</h2>
|
||||
<p class="demo-description">How the table looks when no data is available.</p>
|
||||
<dees-table
|
||||
heading1="No Data Available"
|
||||
heading2="This table is currently empty"
|
||||
.data=${[]}
|
||||
dataName="items"
|
||||
></dees-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
@@ -1,920 +0,0 @@
|
||||
import * as plugins from './00plugins.js';
|
||||
import { demoFunc } from './dees-table.demo.js';
|
||||
import { cssGeistFontFamily } from './00fonts.js';
|
||||
import {
|
||||
customElement,
|
||||
html,
|
||||
DeesElement,
|
||||
property,
|
||||
type TemplateResult,
|
||||
cssManager,
|
||||
css,
|
||||
directives,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import { DeesContextmenu } from './dees-contextmenu.js';
|
||||
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
import { type TIconKey } from './dees-icon.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-table': DeesTable<any>;
|
||||
}
|
||||
}
|
||||
|
||||
// interfaces
|
||||
export interface ITableAction<T = any> {
|
||||
name: string;
|
||||
iconName: TIconKey;
|
||||
/**
|
||||
* the table behaviour to use for this action
|
||||
* e.g. upload: allows to upload files to the table
|
||||
*/
|
||||
useTableBehaviour?: 'upload' | 'cancelUpload' | 'none';
|
||||
/**
|
||||
* the type of the action
|
||||
*/
|
||||
type: (
|
||||
| 'inRow'
|
||||
| 'contextmenu'
|
||||
| 'doubleClick'
|
||||
| 'footer'
|
||||
| 'header'
|
||||
| 'preview'
|
||||
| 'keyCombination'
|
||||
)[];
|
||||
/**
|
||||
* allows to check if the action is relevant for the given item
|
||||
* @param itemArg
|
||||
* @returns
|
||||
*/
|
||||
actionRelevancyCheckFunc?: (itemArg: T) => boolean;
|
||||
/**
|
||||
* the actual action function implementation
|
||||
* @param itemArg
|
||||
* @returns
|
||||
*/
|
||||
actionFunc: (actionDataArg: ITableActionDataArg<T>) => Promise<any>;
|
||||
}
|
||||
|
||||
export interface ITableActionDataArg<T> {
|
||||
item: T;
|
||||
table: DeesTable<T>;
|
||||
}
|
||||
|
||||
export type TDisplayFunction<T = any> = (itemArg: T) => object;
|
||||
|
||||
// the table implementation
|
||||
@customElement('dees-table')
|
||||
export class DeesTable<T> extends DeesElement {
|
||||
public static demo = demoFunc;
|
||||
|
||||
// INSTANCE
|
||||
@property({
|
||||
type: String,
|
||||
})
|
||||
public heading1: string = 'heading 1';
|
||||
|
||||
@property({
|
||||
type: String,
|
||||
})
|
||||
public heading2: string = 'heading 2';
|
||||
|
||||
@property({
|
||||
type: Array,
|
||||
})
|
||||
public data: T[] = [];
|
||||
|
||||
// dees-form compatibility -----------------------------------------
|
||||
@property({
|
||||
type: String,
|
||||
})
|
||||
public key: string;
|
||||
|
||||
@property({
|
||||
type: String,
|
||||
})
|
||||
public label: string;
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
})
|
||||
public disabled: boolean = false;
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
})
|
||||
public required: boolean = false;
|
||||
|
||||
get value() {
|
||||
return this.data;
|
||||
}
|
||||
set value(_valueArg) {}
|
||||
public changeSubject = new domtools.plugins.smartrx.rxjs.Subject<DeesTable<T>>();
|
||||
// end dees-form compatibility -----------------------------------------
|
||||
|
||||
/**
|
||||
* What does a row of data represent?
|
||||
*/
|
||||
@property({
|
||||
type: String,
|
||||
reflect: true,
|
||||
})
|
||||
public dataName: string;
|
||||
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
})
|
||||
searchable: boolean = true;
|
||||
|
||||
@property({
|
||||
type: Array,
|
||||
})
|
||||
public dataActions: ITableAction<T>[] = [];
|
||||
|
||||
@property({
|
||||
attribute: false,
|
||||
})
|
||||
public displayFunction: TDisplayFunction = (itemArg: T) => itemArg as any;
|
||||
|
||||
@property({
|
||||
attribute: false,
|
||||
})
|
||||
public reverseDisplayFunction: (itemArg: any) => T = (itemArg: any) => itemArg as T;
|
||||
|
||||
@property({
|
||||
type: Object,
|
||||
})
|
||||
public selectedDataRow: T;
|
||||
|
||||
@property({
|
||||
type: Array,
|
||||
})
|
||||
public editableFields: string[] = [];
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
reflect: true,
|
||||
attribute: 'show-vertical-lines'
|
||||
})
|
||||
public showVerticalLines: boolean = false;
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
reflect: true,
|
||||
attribute: 'show-horizontal-lines'
|
||||
})
|
||||
public showHorizontalLines: boolean = false;
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
reflect: true,
|
||||
attribute: 'show-grid'
|
||||
})
|
||||
public showGrid: boolean = true;
|
||||
|
||||
public files: File[] = [];
|
||||
public fileWeakMap = new WeakMap();
|
||||
|
||||
public dataChangeSubject = new domtools.plugins.smartrx.rxjs.Subject();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mainbox {
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
|
||||
font-family: ${cssGeistFontFamily};
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
min-height: 64px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
}
|
||||
|
||||
.headingContainer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.heading {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.heading1 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.heading2 {
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.headingSeparation {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.headerAction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.headerAction:hover {
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
|
||||
border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
|
||||
}
|
||||
|
||||
.headerAction dees-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.searchGrid {
|
||||
display: grid;
|
||||
grid-gap: 16px;
|
||||
grid-template-columns: 1fr 200px;
|
||||
padding: 16px 24px;
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 98%)', 'hsl(0 0% 3.9%)')};
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.searchGrid.hidden {
|
||||
height: 0px;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
padding: 0px 24px;
|
||||
border-bottom-width: 0px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
caption-side: bottom;
|
||||
font-size: 14px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.noDataSet {
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
|
||||
}
|
||||
|
||||
thead {
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 9%)')};
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
transition: background-color 0.15s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Default horizontal lines (bottom border only) */
|
||||
tbody tr {
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
}
|
||||
|
||||
tbody tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Full horizontal lines when enabled */
|
||||
:host([show-horizontal-lines]) tbody tr {
|
||||
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
}
|
||||
|
||||
:host([show-horizontal-lines]) tbody tr:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
:host([show-horizontal-lines]) tbody tr:last-child {
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1% / 0.5)', 'hsl(0 0% 14.9% / 0.5)')};
|
||||
}
|
||||
|
||||
/* Column hover effect for better traceability */
|
||||
td {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
td::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -1000px;
|
||||
bottom: -1000px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1% / 0.3)', 'hsl(0 0% 14.9% / 0.3)')};
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
td:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Grid mode - shows both vertical and horizontal lines */
|
||||
:host([show-grid]) th {
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-left: none;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
:host([show-grid]) td {
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-left: none;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
:host([show-grid]) th:first-child,
|
||||
:host([show-grid]) td:first-child {
|
||||
border-left: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
}
|
||||
|
||||
:host([show-grid]) tbody tr:first-child td {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
tbody tr.selected {
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 14.9%)')};
|
||||
}
|
||||
|
||||
tbody tr.hasAttachment {
|
||||
background: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3% / 0.1)', 'hsl(142.1 76.2% 36.3% / 0.1)')};
|
||||
}
|
||||
|
||||
th {
|
||||
height: 48px;
|
||||
padding: 12px 24px;
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
:host([show-vertical-lines]) th {
|
||||
border-right: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px 24px;
|
||||
vertical-align: middle;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
|
||||
}
|
||||
|
||||
:host([show-vertical-lines]) td {
|
||||
border-right: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
}
|
||||
|
||||
th:first-child,
|
||||
td:first-child {
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
th:last-child,
|
||||
td:last-child {
|
||||
padding-right: 24px;
|
||||
}
|
||||
|
||||
:host([show-vertical-lines]) th:last-child,
|
||||
:host([show-vertical-lines]) td:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.innerCellContainer {
|
||||
position: relative;
|
||||
min-height: 24px;
|
||||
line-height: 24px;
|
||||
}
|
||||
td input {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
width: calc(100% - 40px);
|
||||
height: calc(100% - 8px);
|
||||
padding: 0 12px;
|
||||
outline: none;
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 6px;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
transition: all 0.15s ease;
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
}
|
||||
|
||||
td input:focus {
|
||||
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.2)', 'hsl(217.2 91.2% 59.8% / 0.2)')};
|
||||
}
|
||||
.actionsContainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.action:hover {
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 14.9%)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||
}
|
||||
|
||||
.action:active {
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 11.8%)')};
|
||||
}
|
||||
|
||||
.action dees-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 52px;
|
||||
padding: 0 24px;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 9%)')};
|
||||
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
}
|
||||
|
||||
.tableStatistics {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.footerActions .footerAction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.footerActions .footerAction:hover {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||
}
|
||||
|
||||
.footerActions .footerAction dees-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="mainbox">
|
||||
<!-- the heading part -->
|
||||
<div class="header">
|
||||
<div class="headingContainer">
|
||||
<div class="heading heading1">${this.label || this.heading1}</div>
|
||||
<div class="heading heading2">${this.heading2}</div>
|
||||
</div>
|
||||
<div class="headerActions">
|
||||
${directives.resolveExec(async () => {
|
||||
const resultArray: TemplateResult[] = [];
|
||||
for (const action of this.dataActions) {
|
||||
if (!action.type.includes('header')) continue;
|
||||
resultArray.push(
|
||||
html`<div
|
||||
class="headerAction"
|
||||
@click=${() => {
|
||||
action.actionFunc({
|
||||
item: this.selectedDataRow,
|
||||
table: this,
|
||||
});
|
||||
}}
|
||||
>
|
||||
${action.iconName
|
||||
? html`<dees-icon .iconSize=${14} .icon=${action.iconName}></dees-icon>
|
||||
${action.name}`
|
||||
: action.name}
|
||||
</div>`
|
||||
);
|
||||
}
|
||||
return resultArray;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div class="headingSeparation"></div>
|
||||
<div class="searchGrid hidden">
|
||||
<dees-input-text
|
||||
.label=${'lucene syntax search'}
|
||||
.description=${`
|
||||
You can use the lucene syntax to search for data, e.g.:
|
||||
|
||||
\`\`\`
|
||||
name: "john" AND age: 18
|
||||
\`\`\`
|
||||
|
||||
`}
|
||||
></dees-input-text>
|
||||
<dees-input-multitoggle
|
||||
.label=${'search mode'}
|
||||
.options=${['table', 'data', 'server']}
|
||||
.selectedOption=${'table'}
|
||||
.description=${`
|
||||
There are three basic modes:
|
||||
|
||||
* table: only searches data already in the table
|
||||
* data: searches original data, ignoring table transforms
|
||||
* server: searches data on the server
|
||||
|
||||
`}
|
||||
></dees-input-multitoggle>
|
||||
</div>
|
||||
|
||||
<!-- the actual table -->
|
||||
<style></style>
|
||||
${this.data.length > 0
|
||||
? (() => {
|
||||
// Only pick up the keys from the first transformed data object
|
||||
// as all data objects are assumed to have the same structure
|
||||
const firstTransformedItem = this.displayFunction(this.data[0]);
|
||||
const headings: string[] = Object.keys(firstTransformedItem);
|
||||
return html`
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
${headings.map(
|
||||
(headingArg) => html`
|
||||
<th>${headingArg}</th>
|
||||
`
|
||||
)}
|
||||
${(() => {
|
||||
if (this.dataActions && this.dataActions.length > 0) {
|
||||
return html`
|
||||
<th>Actions</th>
|
||||
`;
|
||||
}
|
||||
})()}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${this.data.map((itemArg) => {
|
||||
const transformedItem = this.displayFunction(itemArg);
|
||||
const getTr = (elementArg: HTMLElement): HTMLElement => {
|
||||
if (elementArg.tagName === 'TR') {
|
||||
return elementArg;
|
||||
} else {
|
||||
return getTr(elementArg.parentElement);
|
||||
}
|
||||
};
|
||||
return html`
|
||||
<tr
|
||||
@click=${() => {
|
||||
this.selectedDataRow = itemArg;
|
||||
}}
|
||||
@dragenter=${async (eventArg: DragEvent) => {
|
||||
eventArg.preventDefault();
|
||||
eventArg.stopPropagation();
|
||||
const realTarget = getTr(eventArg.target as HTMLElement);
|
||||
console.log('dragenter');
|
||||
console.log(realTarget);
|
||||
setTimeout(() => {
|
||||
realTarget.classList.add('hasAttachment');
|
||||
}, 0);
|
||||
}}
|
||||
@dragleave=${async (eventArg: DragEvent) => {
|
||||
eventArg.preventDefault();
|
||||
eventArg.stopPropagation();
|
||||
const realTarget = getTr(eventArg.target as HTMLElement);
|
||||
realTarget.classList.remove('hasAttachment');
|
||||
}}
|
||||
@dragover=${async (eventArg: DragEvent) => {
|
||||
eventArg.preventDefault();
|
||||
}}
|
||||
@drop=${async (eventArg: DragEvent) => {
|
||||
eventArg.preventDefault();
|
||||
const newFiles = [];
|
||||
for (const file of Array.from(eventArg.dataTransfer.files)) {
|
||||
this.files.push(file);
|
||||
newFiles.push(file);
|
||||
this.requestUpdate();
|
||||
}
|
||||
const result: File[] = this.fileWeakMap.get(itemArg as object);
|
||||
if (!result) {
|
||||
this.fileWeakMap.set(itemArg as object, newFiles);
|
||||
} else {
|
||||
result.push(...newFiles);
|
||||
}
|
||||
}}
|
||||
@contextmenu=${async (eventArg: MouseEvent) => {
|
||||
DeesContextmenu.openContextMenuWithOptions(
|
||||
eventArg,
|
||||
this.getActionsForType('contextmenu').map((action) => {
|
||||
const menuItem: plugins.tsclass.website.IMenuItem = {
|
||||
name: action.name,
|
||||
iconName: action.iconName as any,
|
||||
action: async () => {
|
||||
await action.actionFunc({
|
||||
item: itemArg,
|
||||
table: this,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
};
|
||||
return menuItem;
|
||||
})
|
||||
);
|
||||
}}
|
||||
class="${itemArg === this.selectedDataRow ? 'selected' : ''}"
|
||||
>
|
||||
${headings.map(
|
||||
(headingArg) => html`
|
||||
<td
|
||||
@dblclick=${(e: Event) => {
|
||||
if (this.editableFields.includes(headingArg)) {
|
||||
this.handleCellEditing(e, itemArg, headingArg);
|
||||
} else {
|
||||
const wantedAction = this.dataActions.find((actionArg) =>
|
||||
actionArg.type.includes('doubleClick')
|
||||
);
|
||||
if (wantedAction) {
|
||||
wantedAction.actionFunc({
|
||||
item: itemArg,
|
||||
table: this,
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="innerCellContainer">${transformedItem[headingArg]}</div>
|
||||
</td>
|
||||
`
|
||||
)}
|
||||
${(() => {
|
||||
if (this.dataActions && this.dataActions.length > 0) {
|
||||
return html`
|
||||
<td>
|
||||
<div class="actionsContainer">
|
||||
${this.getActionsForType('inRow').map(
|
||||
(actionArg) => html`
|
||||
<div
|
||||
class="action"
|
||||
@click=${() =>
|
||||
actionArg.actionFunc({
|
||||
item: itemArg,
|
||||
table: this,
|
||||
})}
|
||||
>
|
||||
${actionArg.iconName
|
||||
? html`
|
||||
<dees-icon
|
||||
.icon=${actionArg.iconName}
|
||||
></dees-icon>
|
||||
`
|
||||
: actionArg.name}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
}
|
||||
})()}
|
||||
</tr>
|
||||
`;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
})()
|
||||
: html` <div class="noDataSet">No data set!</div> `}
|
||||
<div class="footer">
|
||||
<div class="tableStatistics">
|
||||
${this.data.length} ${this.dataName || 'data rows'} (total) |
|
||||
${this.selectedDataRow ? '# ' + `${this.data.indexOf(this.selectedDataRow) + 1}` : `No`}
|
||||
selected
|
||||
</div>
|
||||
<div class="footerActions">
|
||||
${directives.resolveExec(async () => {
|
||||
const resultArray: TemplateResult[] = [];
|
||||
for (const action of this.dataActions) {
|
||||
if (!action.type.includes('footer')) continue;
|
||||
resultArray.push(
|
||||
html`<div
|
||||
class="footerAction"
|
||||
@click=${() => {
|
||||
action.actionFunc({
|
||||
item: this.selectedDataRow,
|
||||
table: this,
|
||||
});
|
||||
}}
|
||||
>
|
||||
${action.iconName
|
||||
? html`<dees-icon .iconSize=${14} .icon=${action.iconName}></dees-icon>
|
||||
${action.name}`
|
||||
: action.name}
|
||||
</div>`
|
||||
);
|
||||
}
|
||||
return resultArray;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
public async firstUpdated() {
|
||||
|
||||
}
|
||||
|
||||
public async updated(changedProperties: Map<string | number | symbol, unknown>): Promise<void> {
|
||||
super.updated(changedProperties);
|
||||
this.determineColumnWidths();
|
||||
if (this.searchable) {
|
||||
const existing = this.dataActions.find((actionArg) => actionArg.type.includes('header') && actionArg.name === 'Search');
|
||||
if (!existing) {
|
||||
this.dataActions.unshift({
|
||||
name: 'Search',
|
||||
iconName: 'magnifyingGlass',
|
||||
type: ['header'],
|
||||
actionFunc: async () => {
|
||||
console.log('open search');
|
||||
const searchGrid = this.shadowRoot.querySelector('.searchGrid');
|
||||
searchGrid.classList.toggle('hidden');
|
||||
}
|
||||
});
|
||||
console.log(this.dataActions);
|
||||
this.requestUpdate();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async determineColumnWidths() {
|
||||
const domtools = await this.domtoolsPromise;
|
||||
await domtools.convenience.smartdelay.delayFor(0);
|
||||
// Get the table element
|
||||
const table = this.shadowRoot.querySelector('table');
|
||||
if (!table) return;
|
||||
|
||||
// Get the first row's cells to measure the widths
|
||||
const cells = table.rows[0].cells;
|
||||
|
||||
const handleColumnByIndex = async (i: number, waitForRenderArg: boolean = false) => {
|
||||
const done = plugins.smartpromise.defer();
|
||||
const cell = cells[i];
|
||||
|
||||
// Get computed width
|
||||
const width = window.getComputedStyle(cell).width;
|
||||
if (cell.textContent.includes('Actions')) {
|
||||
const neededWidth =
|
||||
this.dataActions.filter((actionArg) => actionArg.type.includes('inRow')).length * 36;
|
||||
cell.style.width = `${Math.max(neededWidth, 68)}px`;
|
||||
} else {
|
||||
cell.style.width = width;
|
||||
}
|
||||
if (waitForRenderArg) {
|
||||
requestAnimationFrame(() => {
|
||||
done.resolve();
|
||||
});
|
||||
await done.promise;
|
||||
}
|
||||
};
|
||||
|
||||
if (cells[cells.length - 1].textContent.includes('Actions')) {
|
||||
await handleColumnByIndex(cells.length - 1, true);
|
||||
}
|
||||
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
if (cells[i].textContent.includes('Actions')) {
|
||||
continue;
|
||||
}
|
||||
await handleColumnByIndex(i);
|
||||
}
|
||||
table.style.tableLayout = 'fixed';
|
||||
}
|
||||
|
||||
getActionsForType(typeArg: ITableAction['type'][0]) {
|
||||
const actions: ITableAction[] = [];
|
||||
for (const action of this.dataActions) {
|
||||
if (!action.type.includes(typeArg)) continue;
|
||||
actions.push(action);
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
async handleCellEditing(event: Event, itemArg: T, key: string) {
|
||||
await this.domtoolsPromise;
|
||||
const target = event.target as HTMLElement;
|
||||
const originalColor = target.style.color;
|
||||
target.style.color = 'transparent';
|
||||
const transformedItem = this.displayFunction(itemArg);
|
||||
const initialValue = (transformedItem[key] as unknown as string) || '';
|
||||
// Create an input element
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = initialValue;
|
||||
|
||||
const blurInput = async (blurArg = true, saveArg = false) => {
|
||||
if (blurArg) {
|
||||
input.blur();
|
||||
}
|
||||
if (saveArg) {
|
||||
itemArg[key] = input.value as any; // Convert string to T (you might need better type casting depending on your data structure)
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
input.remove();
|
||||
target.style.color = originalColor;
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
// When the input loses focus or the Enter key is pressed, update the data
|
||||
input.addEventListener('blur', () => {
|
||||
blurInput(false, false);
|
||||
});
|
||||
input.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
blurInput(true, true); // This will trigger the blur event handler above
|
||||
}
|
||||
});
|
||||
|
||||
// Replace the cell's content with the input
|
||||
target.appendChild(input);
|
||||
input.focus();
|
||||
}
|
||||
}
|
115
ts_web/elements/dees-table/data.ts
Normal file
115
ts_web/elements/dees-table/data.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { Column, TDisplayFunction } from './types.js';
|
||||
|
||||
export function computeColumnsFromDisplayFunction<T>(
|
||||
displayFunction: TDisplayFunction<T>,
|
||||
data: T[]
|
||||
): Column<T>[] {
|
||||
if (!data || data.length === 0) return [];
|
||||
const firstTransformedItem = displayFunction(data[0]);
|
||||
const keys: string[] = Object.keys(firstTransformedItem);
|
||||
return keys.map((key) => ({
|
||||
key,
|
||||
header: key,
|
||||
value: (row: T) => displayFunction(row)[key],
|
||||
}));
|
||||
}
|
||||
|
||||
export function computeEffectiveColumns<T>(
|
||||
columns: Column<T>[] | undefined,
|
||||
augmentFromDisplayFunction: boolean,
|
||||
displayFunction: TDisplayFunction<T>,
|
||||
data: T[]
|
||||
): Column<T>[] {
|
||||
const base = (columns || []).slice();
|
||||
if (!augmentFromDisplayFunction) return base;
|
||||
const fromDisplay = computeColumnsFromDisplayFunction(displayFunction, data);
|
||||
const existingKeys = new Set(base.map((c) => String(c.key)));
|
||||
for (const col of fromDisplay) {
|
||||
if (!existingKeys.has(String(col.key))) {
|
||||
base.push(col);
|
||||
}
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export function getCellValue<T>(row: T, col: Column<T>, displayFunction?: TDisplayFunction<T>): any {
|
||||
return col.value ? col.value(row) : (row as any)[col.key as any];
|
||||
}
|
||||
|
||||
export function getViewData<T>(
|
||||
data: T[],
|
||||
effectiveColumns: Column<T>[],
|
||||
sortKey?: string,
|
||||
sortDir?: 'asc' | 'desc' | null,
|
||||
filterText?: string,
|
||||
columnFilters?: Record<string, string>,
|
||||
filterMode: 'table' | 'data' = 'table',
|
||||
lucenePredicate?: (row: T) => boolean
|
||||
): T[] {
|
||||
let arr = data.slice();
|
||||
const ft = (filterText || '').trim().toLowerCase();
|
||||
const cf = columnFilters || {};
|
||||
const cfKeys = Object.keys(cf).filter((k) => (cf[k] ?? '').trim().length > 0);
|
||||
if (ft || cfKeys.length > 0) {
|
||||
arr = arr.filter((row) => {
|
||||
// column filters (AND across columns)
|
||||
for (const k of cfKeys) {
|
||||
if (filterMode === 'data') {
|
||||
// raw object check for that key
|
||||
const val = (row as any)[k];
|
||||
const s = String(val ?? '').toLowerCase();
|
||||
const needle = String(cf[k]).toLowerCase();
|
||||
if (!s.includes(needle)) return false;
|
||||
} else {
|
||||
const col = effectiveColumns.find((c) => String(c.key) === k);
|
||||
if (!col || col.hidden || col.filterable === false) continue;
|
||||
const val = getCellValue(row, col);
|
||||
const s = String(val ?? '').toLowerCase();
|
||||
const needle = String(cf[k]).toLowerCase();
|
||||
if (!s.includes(needle)) return false;
|
||||
}
|
||||
}
|
||||
// global filter (OR across visible columns) or lucene predicate
|
||||
if (ft) {
|
||||
if (lucenePredicate) {
|
||||
if (!lucenePredicate(row)) return false;
|
||||
return true;
|
||||
}
|
||||
let any = false;
|
||||
if (filterMode === 'data') {
|
||||
for (const val of Object.values(row as any)) {
|
||||
const s = String(val ?? '').toLowerCase();
|
||||
if (s.includes(ft)) { any = true; break; }
|
||||
}
|
||||
} else {
|
||||
for (const col of effectiveColumns) {
|
||||
if (col.hidden) continue;
|
||||
const val = getCellValue(row, col);
|
||||
const s = String(val ?? '').toLowerCase();
|
||||
if (s.includes(ft)) { any = true; break; }
|
||||
}
|
||||
}
|
||||
if (!any) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
if (!sortKey || !sortDir) return arr;
|
||||
const col = effectiveColumns.find((c) => String(c.key) === sortKey);
|
||||
if (!col) return arr;
|
||||
const dir = sortDir === 'asc' ? 1 : -1;
|
||||
arr.sort((a, b) => {
|
||||
const va = getCellValue(a, col);
|
||||
const vb = getCellValue(b, col);
|
||||
if (va == null && vb == null) return 0;
|
||||
if (va == null) return -1 * dir;
|
||||
if (vb == null) return 1 * dir;
|
||||
if (typeof va === 'number' && typeof vb === 'number') return (va - vb) * dir;
|
||||
const sa = String(va).toLowerCase();
|
||||
const sb = String(vb).toLowerCase();
|
||||
if (sa < sb) return -1 * dir;
|
||||
if (sa > sb) return 1 * dir;
|
||||
return 0;
|
||||
});
|
||||
return arr;
|
||||
}
|
678
ts_web/elements/dees-table/dees-table.demo.ts
Normal file
678
ts_web/elements/dees-table/dees-table.demo.ts
Normal file
@@ -0,0 +1,678 @@
|
||||
import { type ITableAction } from './dees-table.js';
|
||||
import * as plugins from '../00plugins.js';
|
||||
import { html, css, cssManager } from '@design.estate/dees-element';
|
||||
|
||||
interface ITableDemoData {
|
||||
date: string;
|
||||
amount: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const demoFunc = () => html`
|
||||
<style>
|
||||
${css`
|
||||
.demoWrapper {
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 32px;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95%)', 'hsl(0 0% 5%)')};
|
||||
overflow-y: auto;
|
||||
}
|
||||
.demo-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.demo-section {
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
.demo-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||
}
|
||||
.demo-description {
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.theme-toggle {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 1000;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div class="demoWrapper">
|
||||
<dees-button class="theme-toggle" @click=${() => {
|
||||
document.body.classList.toggle('bright');
|
||||
document.body.classList.toggle('dark');
|
||||
}}>Toggle Theme</dees-button>
|
||||
|
||||
<div class="demo-container">
|
||||
<div class="demo-section">
|
||||
<h2 class="demo-title">Basic Table with Actions</h2>
|
||||
<p class="demo-description">A standard table with row actions, editable fields, and context menu support. Double-click on descriptions to edit. Grid lines are enabled by default.</p>
|
||||
<dees-table
|
||||
heading1="Current Account Statement"
|
||||
heading2="Bunq - Payment Account 2 - April 2021"
|
||||
.editableFields="${['description']}"
|
||||
.data=${[
|
||||
{
|
||||
date: '2021-04-01',
|
||||
amount: '2464.65 €',
|
||||
description: 'Printing Paper (Office Supplies) - STAPLES BREMEN',
|
||||
},
|
||||
{
|
||||
date: '2021-04-02',
|
||||
amount: '165.65 €',
|
||||
description: 'Logitech Mouse (Hardware) - logi.com OnlineShop',
|
||||
},
|
||||
{
|
||||
date: '2021-04-03',
|
||||
amount: '2999,00 €',
|
||||
description: 'Macbook Pro 16inch (Hardware) - Apple.de OnlineShop',
|
||||
},
|
||||
{
|
||||
date: '2021-04-01',
|
||||
amount: '2464.65 €',
|
||||
description: 'Office-Supplies - STAPLES BREMEN',
|
||||
},
|
||||
{
|
||||
date: '2021-04-01',
|
||||
amount: '2464.65 €',
|
||||
description: 'Office-Supplies - STAPLES BREMEN',
|
||||
},
|
||||
]}
|
||||
dataName="transactions"
|
||||
.dataActions="${[
|
||||
{
|
||||
name: 'upload',
|
||||
iconName: 'bell',
|
||||
useTableBehaviour: 'upload',
|
||||
type: ['inRow'],
|
||||
actionFunc: async (optionsArg) => {
|
||||
alert(optionsArg.item.amount);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'visibility',
|
||||
iconName: 'copy',
|
||||
type: ['inRow'],
|
||||
useTableBehaviour: 'preview',
|
||||
actionFunc: async (itemArg: any) => {},
|
||||
},
|
||||
{
|
||||
name: 'create new',
|
||||
iconName: 'instagram',
|
||||
type: ['header'],
|
||||
useTableBehaviour: 'preview',
|
||||
actionFunc: async (itemArg: any) => {},
|
||||
},
|
||||
{
|
||||
name: 'to gallery',
|
||||
iconName: 'message',
|
||||
type: ['footer'],
|
||||
useTableBehaviour: 'preview',
|
||||
actionFunc: async (itemArg: any) => {},
|
||||
},
|
||||
{
|
||||
name: 'copy',
|
||||
iconName: 'copySolid',
|
||||
type: ['contextmenu', 'inRow'],
|
||||
action: async () => {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'edit (from demo)',
|
||||
iconName: 'penToSquare',
|
||||
type: ['contextmenu'],
|
||||
action: async () => {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'paste',
|
||||
iconName: 'pasteSolid',
|
||||
type: ['contextmenu'],
|
||||
action: async () => {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'preview',
|
||||
type: ['doubleClick', 'contextmenu'],
|
||||
iconName: 'eye',
|
||||
actionFunc: async (itemArg) => {
|
||||
alert(itemArg.item.amount);
|
||||
return null;
|
||||
},
|
||||
}
|
||||
] as ITableAction[]}"
|
||||
></dees-table>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2 class="demo-title">Table with Vertical Lines</h2>
|
||||
<p class="demo-description">Enhanced column separation for better data tracking.</p>
|
||||
<dees-table
|
||||
heading1="Product Inventory"
|
||||
heading2="Current stock levels across warehouses"
|
||||
.showVerticalLines=${true}
|
||||
.data=${[
|
||||
{
|
||||
product: 'MacBook Pro 16"',
|
||||
warehouse_a: '45',
|
||||
warehouse_b: '32',
|
||||
warehouse_c: '28',
|
||||
total: '105',
|
||||
status: '✓ In Stock'
|
||||
},
|
||||
{
|
||||
product: 'iPhone 15 Pro',
|
||||
warehouse_a: '120',
|
||||
warehouse_b: '89',
|
||||
warehouse_c: '156',
|
||||
total: '365',
|
||||
status: '✓ In Stock'
|
||||
},
|
||||
{
|
||||
product: 'AirPods Pro',
|
||||
warehouse_a: '0',
|
||||
warehouse_b: '12',
|
||||
warehouse_c: '5',
|
||||
total: '17',
|
||||
status: '⚠ Low Stock'
|
||||
},
|
||||
{
|
||||
product: 'iPad Air',
|
||||
warehouse_a: '23',
|
||||
warehouse_b: '45',
|
||||
warehouse_c: '67',
|
||||
total: '135',
|
||||
status: '✓ In Stock'
|
||||
}
|
||||
]}
|
||||
dataName="products"
|
||||
></dees-table>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2 class="demo-title">Table with Full Grid</h2>
|
||||
<p class="demo-description">Complete grid lines for maximum readability and structure.</p>
|
||||
<dees-table
|
||||
heading1="Server Monitoring Dashboard"
|
||||
heading2="Real-time metrics across regions"
|
||||
.showGrid=${true}
|
||||
.data=${[
|
||||
{
|
||||
server: 'API-1',
|
||||
region: 'US-East',
|
||||
cpu: '45%',
|
||||
memory: '62%',
|
||||
disk: '78%',
|
||||
latency: '12ms',
|
||||
uptime: '99.9%',
|
||||
status: '🟢 Healthy'
|
||||
},
|
||||
{
|
||||
server: 'API-2',
|
||||
region: 'EU-West',
|
||||
cpu: '38%',
|
||||
memory: '55%',
|
||||
disk: '45%',
|
||||
latency: '25ms',
|
||||
uptime: '99.8%',
|
||||
status: '🟢 Healthy'
|
||||
},
|
||||
{
|
||||
server: 'DB-Master',
|
||||
region: 'US-East',
|
||||
cpu: '72%',
|
||||
memory: '81%',
|
||||
disk: '92%',
|
||||
latency: '8ms',
|
||||
uptime: '100%',
|
||||
status: '🟡 Warning'
|
||||
},
|
||||
{
|
||||
server: 'DB-Replica',
|
||||
region: 'EU-West',
|
||||
cpu: '23%',
|
||||
memory: '34%',
|
||||
disk: '45%',
|
||||
latency: '15ms',
|
||||
uptime: '99.7%',
|
||||
status: '🟢 Healthy'
|
||||
},
|
||||
{
|
||||
server: 'Cache-1',
|
||||
region: 'AP-South',
|
||||
cpu: '89%',
|
||||
memory: '92%',
|
||||
disk: '12%',
|
||||
latency: '120ms',
|
||||
uptime: '98.5%',
|
||||
status: '🔴 Critical'
|
||||
}
|
||||
]}
|
||||
dataName="servers"
|
||||
.dataActions="${[
|
||||
{
|
||||
name: 'SSH Connect',
|
||||
iconName: 'lucide:terminal',
|
||||
type: ['inRow'],
|
||||
actionFunc: async (optionsArg) => {
|
||||
console.log('Connecting to:', optionsArg.item.server);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'View Logs',
|
||||
iconName: 'lucide:file-text',
|
||||
type: ['inRow', 'contextmenu'],
|
||||
actionFunc: async (optionsArg) => {
|
||||
console.log('Viewing logs for:', optionsArg.item.server);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Restart Server',
|
||||
iconName: 'lucide:refresh-cw',
|
||||
type: ['contextmenu'],
|
||||
actionFunc: async (optionsArg) => {
|
||||
console.log('Restarting:', optionsArg.item.server);
|
||||
},
|
||||
}
|
||||
] as ITableAction[]}"
|
||||
></dees-table>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2 class="demo-title">Table with Horizontal Lines Only</h2>
|
||||
<p class="demo-description">Emphasis on row separation without column dividers.</p>
|
||||
<dees-table
|
||||
heading1="Sales Performance"
|
||||
heading2="Top performers this quarter"
|
||||
.showHorizontalLines=${true}
|
||||
.showVerticalLines=${false}
|
||||
.data=${[
|
||||
{
|
||||
salesperson: 'Emily Johnson',
|
||||
region: 'North America',
|
||||
deals_closed: '42',
|
||||
revenue: '$1.2M',
|
||||
quota_achievement: '128%',
|
||||
rating: '⭐⭐⭐⭐⭐'
|
||||
},
|
||||
{
|
||||
salesperson: 'Michael Chen',
|
||||
region: 'Asia Pacific',
|
||||
deals_closed: '38',
|
||||
revenue: '$980K',
|
||||
quota_achievement: '115%',
|
||||
rating: '⭐⭐⭐⭐⭐'
|
||||
},
|
||||
{
|
||||
salesperson: 'Sarah Williams',
|
||||
region: 'Europe',
|
||||
deals_closed: '35',
|
||||
revenue: '$875K',
|
||||
quota_achievement: '108%',
|
||||
rating: '⭐⭐⭐⭐'
|
||||
},
|
||||
{
|
||||
salesperson: 'David Garcia',
|
||||
region: 'Latin America',
|
||||
deals_closed: '31',
|
||||
revenue: '$750K',
|
||||
quota_achievement: '95%',
|
||||
rating: '⭐⭐⭐⭐'
|
||||
}
|
||||
]}
|
||||
dataName="sales reps"
|
||||
></dees-table>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2 class="demo-title">Simple Table (No Grid)</h2>
|
||||
<p class="demo-description">Clean, minimal design without grid lines. Set showGrid to false to disable the default grid.</p>
|
||||
<dees-table
|
||||
heading1="Team Members"
|
||||
heading2="Engineering Department"
|
||||
.showGrid=${false}
|
||||
.data=${[
|
||||
{
|
||||
name: 'Alice Johnson',
|
||||
role: 'Lead Engineer',
|
||||
email: 'alice@company.com',
|
||||
location: 'San Francisco',
|
||||
joined: '2020-03-15'
|
||||
},
|
||||
{
|
||||
name: 'Bob Smith',
|
||||
role: 'Senior Developer',
|
||||
email: 'bob@company.com',
|
||||
location: 'New York',
|
||||
joined: '2019-07-22'
|
||||
},
|
||||
{
|
||||
name: 'Charlie Davis',
|
||||
role: 'DevOps Engineer',
|
||||
email: 'charlie@company.com',
|
||||
location: 'London',
|
||||
joined: '2021-01-10'
|
||||
},
|
||||
{
|
||||
name: 'Diana Martinez',
|
||||
role: 'Frontend Developer',
|
||||
email: 'diana@company.com',
|
||||
location: 'Barcelona',
|
||||
joined: '2022-05-18'
|
||||
}
|
||||
]}
|
||||
dataName="team members"
|
||||
></dees-table>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2 class="demo-title">Table with Custom Display Function</h2>
|
||||
<p class="demo-description">Transform data for display using custom formatting.</p>
|
||||
<dees-table
|
||||
heading1="Sales Report"
|
||||
heading2="Q4 2023 Performance"
|
||||
.data=${[
|
||||
{
|
||||
product: 'Enterprise License',
|
||||
units: 45,
|
||||
revenue: 225000,
|
||||
growth: 0.23,
|
||||
forecast: 280000
|
||||
},
|
||||
{
|
||||
product: 'Professional License',
|
||||
units: 128,
|
||||
revenue: 128000,
|
||||
growth: 0.15,
|
||||
forecast: 147000
|
||||
},
|
||||
{
|
||||
product: 'Starter License',
|
||||
units: 342,
|
||||
revenue: 68400,
|
||||
growth: 0.42,
|
||||
forecast: 97000
|
||||
}
|
||||
]}
|
||||
.displayFunction=${(item) => ({
|
||||
Product: item.product,
|
||||
'Units Sold': item.units.toLocaleString(),
|
||||
Revenue: '$' + item.revenue.toLocaleString(),
|
||||
Growth: (item.growth * 100).toFixed(1) + '%',
|
||||
'Q1 2024 Forecast': '$' + item.forecast.toLocaleString()
|
||||
})}
|
||||
dataName="products"
|
||||
></dees-table>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2 class="demo-title">Empty Table State</h2>
|
||||
<p class="demo-description">How the table looks when no data is available.</p>
|
||||
<dees-table
|
||||
heading1="No Data Available"
|
||||
heading2="This table is currently empty"
|
||||
.data=${[]}
|
||||
dataName="items"
|
||||
></dees-table>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2 class="demo-title">Schema-First Columns (New)</h2>
|
||||
<p class="demo-description">Defines columns explicitly and renders via schema. No displayFunction needed.</p>
|
||||
<dees-table
|
||||
heading1="Users (Schema-First)"
|
||||
heading2="Columns define rendering and order"
|
||||
.columns=${[
|
||||
{ key: 'name', header: 'Name', sortable: true },
|
||||
{ key: 'email', header: 'Email', renderer: (v: string) => html`<dees-badge>${v}</dees-badge>` },
|
||||
{ key: 'joinedAt', header: 'Joined', renderer: (v: string) => new Date(v).toLocaleDateString() },
|
||||
]}
|
||||
.data=${[
|
||||
{ name: 'Alice', email: 'alice@example.com', joinedAt: '2022-08-01' },
|
||||
{ name: 'Bob', email: 'bob@example.com', joinedAt: '2021-12-11' },
|
||||
{ name: 'Carol', email: 'carol@example.com', joinedAt: '2023-03-22' },
|
||||
]}
|
||||
dataName="users"
|
||||
></dees-table>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2 class="demo-title">Partial Schema + Augment (New)</h2>
|
||||
<p class="demo-description">Provides only the important columns; the rest are merged in from displayFunction.</p>
|
||||
<dees-table
|
||||
heading1="Users (Partial + Augment)"
|
||||
heading2="Missing columns are derived"
|
||||
.columns=${[
|
||||
{ key: 'name', header: 'Name', sortable: true },
|
||||
]}
|
||||
.displayFunction=${(u: any) => ({ name: u.name, email: u.email, role: u.role })}
|
||||
.augmentFromDisplayFunction=${true}
|
||||
.data=${[
|
||||
{ name: 'Erin', email: 'erin@example.com', role: 'Admin' },
|
||||
{ name: 'Finn', email: 'finn@example.com', role: 'User' },
|
||||
{ name: 'Gina', email: 'gina@example.com', role: 'User' },
|
||||
]}
|
||||
dataName="users"
|
||||
></dees-table>
|
||||
</div>
|
||||
|
||||
<div class="demo-section"
|
||||
@selectionChange=${(e: CustomEvent) => { console.log('Selection changed', e.detail); }}
|
||||
@search-changed=${(e: CustomEvent) => {
|
||||
const tbl = document.getElementById('tableFilterSelectDemo') as any;
|
||||
if (tbl) tbl.setFilterText(e.detail.value);
|
||||
}}
|
||||
@search-submit=${(e: CustomEvent) => {
|
||||
const tbl = document.getElementById('tableFilterSelectDemo') as any;
|
||||
if (tbl) tbl.setFilterText(e.detail.value);
|
||||
}}
|
||||
>
|
||||
<h2 class="demo-title">Filtering + Multi-Selection (New)</h2>
|
||||
<p class="demo-description">Use the search bar to filter rows; toggle selection via checkboxes. Click headers to sort.</p>
|
||||
<dees-searchbar></dees-searchbar>
|
||||
<div style="height: 12px"></div>
|
||||
<dees-table
|
||||
id="tableFilterSelectDemo"
|
||||
heading1="Inventory (Filter + Select)"
|
||||
heading2="Try typing to filter and selecting multiple rows"
|
||||
.selectionMode=${'multi'}
|
||||
.rowKey=${'sku'}
|
||||
.columns=${[
|
||||
{ key: 'sku', header: 'SKU', sortable: true },
|
||||
{ key: 'name', header: 'Name', sortable: true },
|
||||
{ key: 'stock', header: 'Stock', sortable: true },
|
||||
]}
|
||||
.data=${[
|
||||
{ sku: 'A-100', name: 'USB-C Cable', stock: 120 },
|
||||
{ sku: 'A-101', name: 'Wireless Mouse', stock: 55 },
|
||||
{ sku: 'A-102', name: 'Laptop Stand', stock: 18 },
|
||||
{ sku: 'B-200', name: 'Keyboard (ISO)', stock: 89 },
|
||||
{ sku: 'B-201', name: 'HDMI Adapter', stock: 0 },
|
||||
{ sku: 'C-300', name: 'Webcam 1080p', stock: 42 },
|
||||
]}
|
||||
dataName="items"
|
||||
></dees-table>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2 class="demo-title">Column Filters + Sticky Header (New)</h2>
|
||||
<p class="demo-description">Per-column quick filters and sticky header with internal scroll. Try filtering the Name column. Uses --table-max-height var.</p>
|
||||
<style>
|
||||
dees-table[sticky-header] { --table-max-height: 220px; }
|
||||
</style>
|
||||
<dees-table
|
||||
heading1="Employees"
|
||||
heading2="Quick filter per column + sticky header"
|
||||
.showColumnFilters=${true}
|
||||
.stickyHeader=${true}
|
||||
.columns=${[
|
||||
{ key: 'name', header: 'Name', sortable: true },
|
||||
{ key: 'email', header: 'Email', sortable: true },
|
||||
{ key: 'department', header: 'Department', sortable: true },
|
||||
]}
|
||||
.data=${[
|
||||
{ name: 'Alice Johnson', email: 'alice@corp.com', department: 'Engineering' },
|
||||
{ name: 'Bob Smith', email: 'bob@corp.com', department: 'Sales' },
|
||||
{ name: 'Charlie Davis', email: 'charlie@corp.com', department: 'HR' },
|
||||
{ name: 'Diana Martinez', email: 'diana@corp.com', department: 'Engineering' },
|
||||
{ name: 'Ethan Brown', email: 'ethan@corp.com', department: 'Finance' },
|
||||
{ name: 'Fiona Clark', email: 'fiona@corp.com', department: 'Sales' },
|
||||
{ name: 'Grace Lee', email: 'grace@corp.com', department: 'Engineering' },
|
||||
{ name: 'Henry Wilson', email: 'henry@corp.com', department: 'Marketing' },
|
||||
{ name: 'Irene Walker', email: 'irene@corp.com', department: 'Finance' },
|
||||
{ name: 'Jack Turner', email: 'jack@corp.com', department: 'Support' },
|
||||
]}
|
||||
dataName="employees"
|
||||
></dees-table>
|
||||
</div>
|
||||
|
||||
<div class="demo-section"
|
||||
@searchRequest=${async (e: CustomEvent) => {
|
||||
const { query } = e.detail || { query: '' };
|
||||
const table = document.getElementById('serverSearchDemo') as any;
|
||||
const baseData = [
|
||||
{ id: 1, name: 'Alice', city: 'Berlin', title: 'Engineer' },
|
||||
{ id: 2, name: 'Bob', city: 'Paris', title: 'Designer' },
|
||||
{ id: 3, name: 'Charlie', city: 'London', title: 'Manager' },
|
||||
{ id: 4, name: 'Diana', city: 'Madrid', title: 'Engineer' },
|
||||
{ id: 5, name: 'Ethan', city: 'Rome', title: 'Support' },
|
||||
];
|
||||
// Simulate async request
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
const q = String(query || '').toLowerCase();
|
||||
const filtered = q
|
||||
? baseData.filter((r) => Object.values(r).some((v) => String(v).toLowerCase().includes(q)))
|
||||
: baseData;
|
||||
table.data = filtered;
|
||||
}}
|
||||
>
|
||||
<h2 class="demo-title">Server Search (New)</h2>
|
||||
<p class="demo-description">Select Server mode, type a query, and watch the table fetch simulated results.</p>
|
||||
<dees-table
|
||||
id="serverSearchDemo"
|
||||
heading1="People (Server Search)"
|
||||
heading2="Click Search, choose Server mode, and type"
|
||||
.columns=${[
|
||||
{ key: 'name', header: 'Name' },
|
||||
{ key: 'city', header: 'City' },
|
||||
{ key: 'title', header: 'Title' },
|
||||
]}
|
||||
.data=${[
|
||||
{ id: 1, name: 'Alice', city: 'Berlin', title: 'Engineer' },
|
||||
{ id: 2, name: 'Bob', city: 'Paris', title: 'Designer' },
|
||||
{ id: 3, name: 'Charlie', city: 'London', title: 'Manager' },
|
||||
{ id: 4, name: 'Diana', city: 'Madrid', title: 'Engineer' },
|
||||
{ id: 5, name: 'Ethan', city: 'Rome', title: 'Support' },
|
||||
]}
|
||||
dataName="people"
|
||||
></dees-table>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2 class="demo-title">Wide Properties + Many Actions</h2>
|
||||
<p class="demo-description">A table with many columns and rich actions to stress test layout and sticky Actions.</p>
|
||||
<dees-table
|
||||
heading1="People Directory"
|
||||
heading2="Many properties and actions"
|
||||
.columns=${[
|
||||
{ key: 'id', header: 'ID', sortable: true },
|
||||
{ key: 'name', header: 'Name', sortable: true },
|
||||
{ key: 'role', header: 'Role', sortable: true },
|
||||
{ key: 'department', header: 'Department', sortable: true },
|
||||
{ key: 'email', header: 'Email' },
|
||||
{ key: 'phone', header: 'Phone' },
|
||||
{ key: 'location', header: 'Location', sortable: true },
|
||||
{ key: 'status', header: 'Status', sortable: true },
|
||||
{ key: 'createdAt', header: 'Created', sortable: true },
|
||||
{ key: 'updatedAt', header: 'Updated', sortable: true },
|
||||
{ key: 'lastLogin', header: 'Last Login', sortable: true },
|
||||
{ key: 'projects', header: 'Projects' },
|
||||
{ key: 'tags', header: 'Tags' },
|
||||
{ key: 'notes', header: 'Notes' },
|
||||
]}
|
||||
.data=${[
|
||||
{ id: 1, name: 'Alice Johnson', role: 'Engineer', department: 'R&D', email: 'alice@corp.com', phone: '+1 202 555 0111', location: 'Berlin', status: 'Active', createdAt: '2023-01-12', updatedAt: '2024-05-03', lastLogin: '2024-10-01', projects: 5, tags: 'typescript, ui', notes: 'Mentor' },
|
||||
{ id: 2, name: 'Bob Smith', role: 'Designer', department: 'Design', email: 'bob@corp.com', phone: '+1 202 555 0112', location: 'Paris', status: 'Active', createdAt: '2022-11-05', updatedAt: '2024-04-10', lastLogin: '2024-09-28', projects: 8, tags: 'figma, brand', notes: 'Part-time' },
|
||||
{ id: 3, name: 'Charlie Davis', role: 'Manager', department: 'Ops', email: 'charlie@corp.com', phone: '+1 202 555 0113', location: 'London', status: 'On Leave', createdAt: '2021-04-21', updatedAt: '2024-02-15', lastLogin: '2024-08-12', projects: 3, tags: 'sre, leadership', notes: '' },
|
||||
{ id: 4, name: 'Diana Martinez', role: 'Engineer', department: 'Platform', email: 'diana@corp.com', phone: '+1 202 555 0114', location: 'Madrid', status: 'Active', createdAt: '2020-06-30', updatedAt: '2024-06-25', lastLogin: '2024-10-02', projects: 6, tags: 'node, api', notes: 'On-call' },
|
||||
{ id: 5, name: 'Ethan Brown', role: 'Support', department: 'CS', email: 'ethan@corp.com', phone: '+1 202 555 0115', location: 'Rome', status: 'Inactive', createdAt: '2019-09-18', updatedAt: '2024-03-09', lastLogin: '2024-06-19', projects: 2, tags: 'zendesk', notes: 'Rehire' },
|
||||
{ id: 6, name: 'Fiona Clark', role: 'QA', department: 'QA', email: 'fiona@corp.com', phone: '+1 202 555 0116', location: 'Vienna', status: 'Active', createdAt: '2022-03-14', updatedAt: '2024-03-01', lastLogin: '2024-09-07', projects: 7, tags: 'playwright', notes: '' },
|
||||
]}
|
||||
.dataActions=${[
|
||||
{ name: 'View', iconName: 'lucide:eye', type: ['inRow', 'contextmenu'], actionFunc: async ({ item }) => { console.log('view', item); } },
|
||||
{ name: 'Edit', iconName: 'lucide:edit', type: ['inRow', 'contextmenu'], actionFunc: async ({ item }) => { console.log('edit', item); } },
|
||||
{ name: 'Delete', iconName: 'lucide:trash', type: ['inRow', 'contextmenu'], actionFunc: async ({ item }) => { console.log('delete', item); } },
|
||||
{ name: 'Message', iconName: 'lucide:message-square', type: ['inRow'], actionFunc: async ({ item }) => { console.log('message', item); } },
|
||||
{ name: 'History', iconName: 'lucide:clock', type: ['inRow'], actionFunc: async ({ item }) => { console.log('history', item); } },
|
||||
{ name: 'Add New', iconName: 'lucide:plus', type: ['header'], actionFunc: async ({ table }) => { console.log('add'); } },
|
||||
{ name: 'Export CSV', iconName: 'lucide:download', type: ['header'], actionFunc: async ({ table }) => { console.log('export'); } },
|
||||
{ name: 'Bulk Delete', iconName: 'lucide:trash-2', type: ['footer'], actionFunc: async ({ table }) => { console.log('bulk delete'); } },
|
||||
] as ITableAction[]}
|
||||
></dees-table>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2 class="demo-title">Scrollable Small Height</h2>
|
||||
<p class="demo-description">Same as above, but with many items and a small fixed height to force vertical scrolling inside the table. Actions remain visible on the right; horizontal scroll appears if needed.</p>
|
||||
<style>
|
||||
#scrollSmallHeight { --table-max-height: 240px; }
|
||||
</style>
|
||||
<dees-table
|
||||
id="scrollSmallHeight"
|
||||
.stickyHeader=${true}
|
||||
heading1="People Directory (Scrollable)"
|
||||
heading2="Forced scrolling with many items"
|
||||
.columns=${[
|
||||
{ key: 'id', header: 'ID', sortable: true },
|
||||
{ key: 'name', header: 'Name', sortable: true },
|
||||
{ key: 'role', header: 'Role', sortable: true },
|
||||
{ key: 'department', header: 'Department', sortable: true },
|
||||
{ key: 'email', header: 'Email' },
|
||||
{ key: 'phone', header: 'Phone' },
|
||||
{ key: 'location', header: 'Location', sortable: true },
|
||||
{ key: 'status', header: 'Status', sortable: true },
|
||||
{ key: 'createdAt', header: 'Created', sortable: true },
|
||||
{ key: 'updatedAt', header: 'Updated', sortable: true },
|
||||
{ key: 'lastLogin', header: 'Last Login', sortable: true },
|
||||
{ key: 'projects', header: 'Projects' },
|
||||
{ key: 'tags', header: 'Tags' },
|
||||
{ key: 'notes', header: 'Notes' },
|
||||
]}
|
||||
.data=${Array.from({ length: 100 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: `User ${i + 1}`,
|
||||
role: ['Engineer','Designer','Manager','QA','Support'][i % 5],
|
||||
department: ['R&D','Design','Ops','QA','CS'][i % 5],
|
||||
email: `user${i+1}@corp.com`,
|
||||
phone: `+1 202 555 ${String(1000 + i).slice(-4)}`,
|
||||
location: ['Berlin','Paris','London','Madrid','Rome'][i % 5],
|
||||
status: ['Active','Inactive','On Leave'][i % 3],
|
||||
createdAt: `2023-${String((i%12)+1).padStart(2,'0')}-${String((i%28)+1).padStart(2,'0')}`,
|
||||
updatedAt: `2024-${String(((i+3)%12)+1).padStart(2,'0')}-${String(((i+7)%28)+1).padStart(2,'0')}`,
|
||||
lastLogin: `2024-${String(((i+6)%12)+1).padStart(2,'0')}-${String(((i+10)%28)+1).padStart(2,'0')}`,
|
||||
projects: (i % 12),
|
||||
tags: i % 2 ? 'typescript' : 'design',
|
||||
notes: i % 3 ? '' : 'Note',
|
||||
}))}
|
||||
.dataActions=${[
|
||||
{ name: 'View', iconName: 'lucide:eye', type: ['inRow'], actionFunc: async ({ item }) => {} },
|
||||
{ name: 'Edit', iconName: 'lucide:edit', type: ['inRow'], actionFunc: async ({ item }) => {} },
|
||||
{ name: 'Delete', iconName: 'lucide:trash', type: ['inRow'], actionFunc: async ({ item }) => {} },
|
||||
] as ITableAction[]}
|
||||
></dees-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
841
ts_web/elements/dees-table/dees-table.ts
Normal file
841
ts_web/elements/dees-table/dees-table.ts
Normal file
@@ -0,0 +1,841 @@
|
||||
import * as plugins from '../00plugins.js';
|
||||
import { demoFunc } from './dees-table.demo.js';
|
||||
import { customElement, html, DeesElement, property, type TemplateResult, directives } from '@design.estate/dees-element';
|
||||
|
||||
import { DeesContextmenu } from '../dees-contextmenu.js';
|
||||
import * as domtools from '@design.estate/dees-domtools';
|
||||
import { type TIconKey } from '../dees-icon.js';
|
||||
import { tableStyles } from './styles.js';
|
||||
import type { Column, ITableAction, ITableActionDataArg, TDisplayFunction } from './types.js';
|
||||
import {
|
||||
computeColumnsFromDisplayFunction as computeColumnsFromDisplayFunctionFn,
|
||||
computeEffectiveColumns as computeEffectiveColumnsFn,
|
||||
getCellValue as getCellValueFn,
|
||||
getViewData as getViewDataFn,
|
||||
} from './data.js';
|
||||
import { compileLucenePredicate } from './lucene.js';
|
||||
|
||||
export type { Column, ITableAction, ITableActionDataArg, TDisplayFunction } from './types.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-table': DeesTable<any>;
|
||||
}
|
||||
}
|
||||
|
||||
// interfaces moved to ./types.ts and re-exported above
|
||||
|
||||
// the table implementation
|
||||
@customElement('dees-table')
|
||||
export class DeesTable<T> extends DeesElement {
|
||||
public static demo = demoFunc;
|
||||
|
||||
// INSTANCE
|
||||
@property({
|
||||
type: String,
|
||||
})
|
||||
public heading1: string = 'heading 1';
|
||||
|
||||
@property({
|
||||
type: String,
|
||||
})
|
||||
public heading2: string = 'heading 2';
|
||||
|
||||
@property({
|
||||
type: Array,
|
||||
})
|
||||
public data: T[] = [];
|
||||
|
||||
// dees-form compatibility -----------------------------------------
|
||||
@property({
|
||||
type: String,
|
||||
})
|
||||
public key: string;
|
||||
|
||||
@property({
|
||||
type: String,
|
||||
})
|
||||
public label: string;
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
})
|
||||
public disabled: boolean = false;
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
})
|
||||
public required: boolean = false;
|
||||
|
||||
get value() {
|
||||
return this.data;
|
||||
}
|
||||
set value(_valueArg) {}
|
||||
public changeSubject = new domtools.plugins.smartrx.rxjs.Subject<DeesTable<T>>();
|
||||
// end dees-form compatibility -----------------------------------------
|
||||
|
||||
/**
|
||||
* What does a row of data represent?
|
||||
*/
|
||||
@property({
|
||||
type: String,
|
||||
reflect: true,
|
||||
})
|
||||
public dataName: string;
|
||||
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
})
|
||||
searchable: boolean = true;
|
||||
|
||||
@property({
|
||||
type: Array,
|
||||
})
|
||||
public dataActions: ITableAction<T>[] = [];
|
||||
|
||||
// schema-first columns API
|
||||
@property({ attribute: false })
|
||||
public columns: Column<T>[] = [];
|
||||
|
||||
/**
|
||||
* Stable row identity for selection and updates. If provided as a function,
|
||||
* it is only usable as a property (not via attribute).
|
||||
*/
|
||||
@property({ attribute: false })
|
||||
public rowKey?: keyof T | ((row: T) => string);
|
||||
|
||||
/**
|
||||
* When true and columns are provided, merge any missing columns discovered
|
||||
* via displayFunction into the effective schema.
|
||||
*/
|
||||
@property({ type: Boolean })
|
||||
public augmentFromDisplayFunction: boolean = false;
|
||||
|
||||
@property({
|
||||
attribute: false,
|
||||
})
|
||||
public displayFunction: TDisplayFunction = (itemArg: T) => itemArg as any;
|
||||
|
||||
@property({
|
||||
attribute: false,
|
||||
})
|
||||
public reverseDisplayFunction: (itemArg: any) => T = (itemArg: any) => itemArg as T;
|
||||
|
||||
@property({
|
||||
type: Object,
|
||||
})
|
||||
public selectedDataRow: T;
|
||||
|
||||
@property({
|
||||
type: Array,
|
||||
})
|
||||
public editableFields: string[] = [];
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
reflect: true,
|
||||
attribute: 'show-vertical-lines'
|
||||
})
|
||||
public showVerticalLines: boolean = false;
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
reflect: true,
|
||||
attribute: 'show-horizontal-lines'
|
||||
})
|
||||
public showHorizontalLines: boolean = false;
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
reflect: true,
|
||||
attribute: 'show-grid'
|
||||
})
|
||||
public showGrid: boolean = true;
|
||||
|
||||
public files: File[] = [];
|
||||
public fileWeakMap = new WeakMap();
|
||||
|
||||
public dataChangeSubject = new domtools.plugins.smartrx.rxjs.Subject();
|
||||
|
||||
// simple client-side sorting (Phase 1)
|
||||
@property({ attribute: false })
|
||||
private sortKey?: string;
|
||||
@property({ attribute: false })
|
||||
private sortDir: 'asc' | 'desc' | null = null;
|
||||
|
||||
// simple client-side filtering (Phase 1)
|
||||
@property({ type: String })
|
||||
public filterText: string = '';
|
||||
// per-column quick filters
|
||||
@property({ attribute: false })
|
||||
public columnFilters: Record<string, string> = {};
|
||||
@property({ type: Boolean, attribute: 'show-column-filters' })
|
||||
public showColumnFilters: boolean = false;
|
||||
@property({ type: Boolean, reflect: true, attribute: 'sticky-header' })
|
||||
public stickyHeader: boolean = false;
|
||||
|
||||
// search row state
|
||||
@property({ type: String })
|
||||
public searchMode: 'table' | 'data' | 'server' = 'table';
|
||||
private __searchTextSub?: { unsubscribe?: () => void };
|
||||
private __searchModeSub?: { unsubscribe?: () => void };
|
||||
|
||||
// selection (Phase 1)
|
||||
@property({ type: String })
|
||||
public selectionMode: 'none' | 'single' | 'multi' = 'none';
|
||||
@property({ attribute: false })
|
||||
private selectedIds: Set<string> = new Set();
|
||||
private _rowIdMap = new WeakMap<object, string>();
|
||||
private _rowIdCounter = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static styles = tableStyles;
|
||||
|
||||
public render(): TemplateResult {
|
||||
const usingColumns = Array.isArray(this.columns) && this.columns.length > 0;
|
||||
const effectiveColumns: Column<T>[] = usingColumns
|
||||
? computeEffectiveColumnsFn(this.columns, this.augmentFromDisplayFunction, this.displayFunction, this.data)
|
||||
: computeColumnsFromDisplayFunctionFn(this.displayFunction, this.data);
|
||||
|
||||
const lucenePred = compileLucenePredicate<T>(
|
||||
this.filterText,
|
||||
this.searchMode === 'data' ? 'data' : 'table',
|
||||
effectiveColumns
|
||||
);
|
||||
|
||||
const viewData = getViewDataFn(
|
||||
this.data,
|
||||
effectiveColumns,
|
||||
this.sortKey,
|
||||
this.sortDir,
|
||||
this.filterText,
|
||||
this.columnFilters,
|
||||
this.searchMode === 'data' ? 'data' : 'table',
|
||||
lucenePred || undefined
|
||||
);
|
||||
(this as any)._lastViewData = viewData;
|
||||
return html`
|
||||
<div class="mainbox">
|
||||
<!-- the heading part -->
|
||||
<div class="header">
|
||||
<div class="headingContainer">
|
||||
<div class="heading heading1">${this.label || this.heading1}</div>
|
||||
<div class="heading heading2">${this.heading2}</div>
|
||||
</div>
|
||||
<div class="headerActions">
|
||||
${directives.resolveExec(async () => {
|
||||
const resultArray: TemplateResult[] = [];
|
||||
for (const action of this.dataActions) {
|
||||
if (!action.type.includes('header')) continue;
|
||||
resultArray.push(
|
||||
html`<div
|
||||
class="headerAction"
|
||||
@click=${() => {
|
||||
action.actionFunc({
|
||||
item: this.selectedDataRow,
|
||||
table: this,
|
||||
});
|
||||
}}
|
||||
>
|
||||
${action.iconName
|
||||
? html`<dees-icon .iconSize=${14} .icon=${action.iconName}></dees-icon>
|
||||
${action.name}`
|
||||
: action.name}
|
||||
</div>`
|
||||
);
|
||||
}
|
||||
return resultArray;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div class="headingSeparation"></div>
|
||||
<div class="searchGrid hidden">
|
||||
<dees-input-text
|
||||
.label=${'lucene syntax search'}
|
||||
.description=${`
|
||||
You can use the lucene syntax to search for data, e.g.:
|
||||
|
||||
\`\`\`
|
||||
name: "john" AND age: 18
|
||||
\`\`\`
|
||||
|
||||
`}
|
||||
></dees-input-text>
|
||||
<dees-input-multitoggle
|
||||
.label=${'search mode'}
|
||||
.options=${['table', 'data', 'server']}
|
||||
.selectedOption=${'table'}
|
||||
.description=${`
|
||||
There are three basic modes:
|
||||
|
||||
* table: only searches data already in the table
|
||||
* data: searches original data, ignoring table transforms
|
||||
* server: searches data on the server
|
||||
|
||||
`}
|
||||
></dees-input-multitoggle>
|
||||
</div>
|
||||
|
||||
<!-- the actual table -->
|
||||
<style></style>
|
||||
${this.data.length > 0
|
||||
? html`
|
||||
<div class="tableScroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
${this.selectionMode !== 'none'
|
||||
? html`
|
||||
<th style="width:42px; text-align:center;">
|
||||
${this.selectionMode === 'multi'
|
||||
? html`
|
||||
<dees-input-checkbox
|
||||
.value=${this.areAllVisibleSelected()}
|
||||
.indeterminate=${this.isVisibleSelectionIndeterminate()}
|
||||
@newValue=${(e: CustomEvent<boolean>) => {
|
||||
e.stopPropagation();
|
||||
this.setSelectVisible(e.detail === true);
|
||||
}}
|
||||
></dees-input-checkbox>
|
||||
`
|
||||
: html``}
|
||||
</th>
|
||||
`
|
||||
: html``}
|
||||
${effectiveColumns
|
||||
.filter((c) => !c.hidden)
|
||||
.map((col) => {
|
||||
const isSortable = !!col.sortable;
|
||||
const ariaSort = this.getAriaSort(col);
|
||||
return html`
|
||||
<th
|
||||
role="columnheader"
|
||||
aria-sort=${ariaSort}
|
||||
style="${isSortable ? 'cursor: pointer;' : ''}"
|
||||
@click=${() => (isSortable ? this.toggleSort(col) : null)}
|
||||
>
|
||||
${col.header ?? (col.key as any)}
|
||||
${this.renderSortIndicator(col)}
|
||||
</th>`;
|
||||
})}
|
||||
${(() => {
|
||||
if (this.dataActions && this.dataActions.length > 0) {
|
||||
return html` <th class="actionsCol">Actions</th> `;
|
||||
}
|
||||
})()}
|
||||
</tr>
|
||||
${this.showColumnFilters
|
||||
? html`<tr class="filtersRow">
|
||||
${this.selectionMode !== 'none'
|
||||
? html`<th style="width:42px;"></th>`
|
||||
: html``}
|
||||
${effectiveColumns
|
||||
.filter((c) => !c.hidden)
|
||||
.map((col) => {
|
||||
const key = String(col.key);
|
||||
if (col.filterable === false) return html`<th></th>`;
|
||||
return html`<th>
|
||||
<input type="text" placeholder="Filter..." .value=${this.columnFilters[key] || ''}
|
||||
@input=${(e: Event) => this.setColumnFilter(key, (e.target as HTMLInputElement).value)} />
|
||||
</th>`;
|
||||
})}
|
||||
${(() => {
|
||||
if (this.dataActions && this.dataActions.length > 0) {
|
||||
return html` <th></th> `;
|
||||
}
|
||||
})()}
|
||||
</tr>`
|
||||
: html``}
|
||||
</thead>
|
||||
<tbody>
|
||||
${viewData.map((itemArg, rowIndex) => {
|
||||
const getTr = (elementArg: HTMLElement): HTMLElement => {
|
||||
if (elementArg.tagName === 'TR') {
|
||||
return elementArg;
|
||||
} else {
|
||||
return getTr(elementArg.parentElement);
|
||||
}
|
||||
};
|
||||
return html`
|
||||
<tr
|
||||
@click=${() => {
|
||||
this.selectedDataRow = itemArg;
|
||||
if (this.selectionMode === 'single') {
|
||||
const id = this.getRowId(itemArg);
|
||||
this.selectedIds.clear();
|
||||
this.selectedIds.add(id);
|
||||
this.emitSelectionChange();
|
||||
this.requestUpdate();
|
||||
}
|
||||
}}
|
||||
@dragenter=${async (eventArg: DragEvent) => {
|
||||
eventArg.preventDefault();
|
||||
eventArg.stopPropagation();
|
||||
const realTarget = getTr(eventArg.target as HTMLElement);
|
||||
setTimeout(() => {
|
||||
realTarget.classList.add('hasAttachment');
|
||||
}, 0);
|
||||
}}
|
||||
@dragleave=${async (eventArg: DragEvent) => {
|
||||
eventArg.preventDefault();
|
||||
eventArg.stopPropagation();
|
||||
const realTarget = getTr(eventArg.target as HTMLElement);
|
||||
realTarget.classList.remove('hasAttachment');
|
||||
}}
|
||||
@dragover=${async (eventArg: DragEvent) => {
|
||||
eventArg.preventDefault();
|
||||
}}
|
||||
@drop=${async (eventArg: DragEvent) => {
|
||||
eventArg.preventDefault();
|
||||
const newFiles = [];
|
||||
for (const file of Array.from(eventArg.dataTransfer.files)) {
|
||||
this.files.push(file);
|
||||
newFiles.push(file);
|
||||
this.requestUpdate();
|
||||
}
|
||||
const result: File[] = this.fileWeakMap.get(itemArg as object);
|
||||
if (!result) {
|
||||
this.fileWeakMap.set(itemArg as object, newFiles);
|
||||
} else {
|
||||
result.push(...newFiles);
|
||||
}
|
||||
}}
|
||||
@contextmenu=${async (eventArg: MouseEvent) => {
|
||||
DeesContextmenu.openContextMenuWithOptions(
|
||||
eventArg,
|
||||
this.getActionsForType('contextmenu').map((action) => {
|
||||
const menuItem: plugins.tsclass.website.IMenuItem = {
|
||||
name: action.name,
|
||||
iconName: action.iconName as any,
|
||||
action: async () => {
|
||||
await action.actionFunc({
|
||||
item: itemArg,
|
||||
table: this,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
};
|
||||
return menuItem;
|
||||
})
|
||||
);
|
||||
}}
|
||||
class="${itemArg === this.selectedDataRow ? 'selected' : ''}"
|
||||
>
|
||||
${this.selectionMode !== 'none'
|
||||
? html`<td style="width:42px; text-align:center;">
|
||||
<dees-input-checkbox
|
||||
.value=${this.isRowSelected(itemArg)}
|
||||
@newValue=${(e: CustomEvent<boolean>) => {
|
||||
e.stopPropagation();
|
||||
this.setRowSelected(itemArg, e.detail === true);
|
||||
}}
|
||||
></dees-input-checkbox>
|
||||
</td>`
|
||||
: html``}
|
||||
${effectiveColumns
|
||||
.filter((c) => !c.hidden)
|
||||
.map((col, colIndex) => {
|
||||
const value = getCellValueFn(itemArg, col, this.displayFunction);
|
||||
const content = col.renderer
|
||||
? col.renderer(value, itemArg, { rowIndex, colIndex, column: col })
|
||||
: value;
|
||||
const editKey = String(col.key);
|
||||
return html`
|
||||
<td
|
||||
@dblclick=${(e: Event) => {
|
||||
const dblAction = this.dataActions.find((actionArg) =>
|
||||
actionArg.type.includes('doubleClick')
|
||||
);
|
||||
if (this.editableFields.includes(editKey)) {
|
||||
this.handleCellEditing(e, itemArg, editKey);
|
||||
} else if (dblAction) {
|
||||
dblAction.actionFunc({ item: itemArg, table: this });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="innerCellContainer">${content}</div>
|
||||
</td>
|
||||
`;
|
||||
})}
|
||||
${(() => {
|
||||
if (this.dataActions && this.dataActions.length > 0) {
|
||||
return html`
|
||||
<td class="actionsCol">
|
||||
<div class="actionsContainer">
|
||||
${this.getActionsForType('inRow').map(
|
||||
(actionArg) => html`
|
||||
<div
|
||||
class="action"
|
||||
@click=${() =>
|
||||
actionArg.actionFunc({
|
||||
item: itemArg,
|
||||
table: this,
|
||||
})}
|
||||
>
|
||||
${actionArg.iconName
|
||||
? html` <dees-icon .icon=${actionArg.iconName}></dees-icon> `
|
||||
: actionArg.name}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
}
|
||||
})()}
|
||||
</tr>`;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`
|
||||
: html` <div class="noDataSet">No data set!</div> `}
|
||||
<div class="footer">
|
||||
<div class="tableStatistics">
|
||||
${this.data.length} ${this.dataName || 'data rows'} (total) |
|
||||
${this.selectedDataRow ? '# ' + `${this.data.indexOf(this.selectedDataRow) + 1}` : `No`}
|
||||
selected
|
||||
</div>
|
||||
<div class="footerActions">
|
||||
${directives.resolveExec(async () => {
|
||||
const resultArray: TemplateResult[] = [];
|
||||
for (const action of this.dataActions) {
|
||||
if (!action.type.includes('footer')) continue;
|
||||
resultArray.push(
|
||||
html`<div
|
||||
class="footerAction"
|
||||
@click=${() => {
|
||||
action.actionFunc({
|
||||
item: this.selectedDataRow,
|
||||
table: this,
|
||||
});
|
||||
}}
|
||||
>
|
||||
${action.iconName
|
||||
? html`<dees-icon .iconSize=${14} .icon=${action.iconName}></dees-icon>
|
||||
${action.name}`
|
||||
: action.name}
|
||||
</div>`
|
||||
);
|
||||
}
|
||||
return resultArray;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
public async firstUpdated() {
|
||||
|
||||
}
|
||||
|
||||
public async updated(changedProperties: Map<string | number | symbol, unknown>): Promise<void> {
|
||||
super.updated(changedProperties);
|
||||
this.determineColumnWidths();
|
||||
if (this.searchable) {
|
||||
const existing = this.dataActions.find((actionArg) => actionArg.type.includes('header') && actionArg.name === 'Search');
|
||||
if (!existing) {
|
||||
this.dataActions.unshift({
|
||||
name: 'Search',
|
||||
iconName: 'magnifyingGlass',
|
||||
type: ['header'],
|
||||
actionFunc: async () => {
|
||||
console.log('open search');
|
||||
const searchGrid = this.shadowRoot.querySelector('.searchGrid');
|
||||
searchGrid.classList.toggle('hidden');
|
||||
}
|
||||
});
|
||||
console.log(this.dataActions);
|
||||
this.requestUpdate();
|
||||
};
|
||||
// wire search inputs
|
||||
this.wireSearchInputs();
|
||||
}
|
||||
}
|
||||
|
||||
private __debounceTimer?: any;
|
||||
private debounceRun(fn: () => void, ms = 200) {
|
||||
if (this.__debounceTimer) clearTimeout(this.__debounceTimer);
|
||||
this.__debounceTimer = setTimeout(fn, ms);
|
||||
}
|
||||
|
||||
private wireSearchInputs() {
|
||||
const searchTextEl: any = this.shadowRoot?.querySelector('.searchGrid dees-input-text');
|
||||
const searchModeEl: any = this.shadowRoot?.querySelector('.searchGrid dees-input-multitoggle');
|
||||
if (searchTextEl && !this.__searchTextSub) {
|
||||
this.__searchTextSub = searchTextEl.changeSubject.subscribe((el: any) => {
|
||||
const val: string = el?.value ?? '';
|
||||
this.debounceRun(() => {
|
||||
if (this.searchMode === 'server') {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('searchRequest', {
|
||||
detail: { query: val, mode: 'server' },
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
this.setFilterText(val);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if (searchModeEl && !this.__searchModeSub) {
|
||||
this.__searchModeSub = searchModeEl.changeSubject.subscribe((el: any) => {
|
||||
const mode: string = el?.selectedOption || el?.value || 'table';
|
||||
if (mode === 'table' || mode === 'data' || mode === 'server') {
|
||||
this.searchMode = mode as any;
|
||||
// When switching modes, re-apply current text input
|
||||
const val: string = searchTextEl?.value ?? '';
|
||||
this.debounceRun(() => {
|
||||
if (this.searchMode === 'server') {
|
||||
this.dispatchEvent(new CustomEvent('searchRequest', { detail: { query: val, mode: 'server' }, bubbles: true }));
|
||||
} else {
|
||||
this.setFilterText(val);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async determineColumnWidths() {
|
||||
const domtools = await this.domtoolsPromise;
|
||||
await domtools.convenience.smartdelay.delayFor(0);
|
||||
// Get the table element
|
||||
const table = this.shadowRoot.querySelector('table');
|
||||
if (!table) return;
|
||||
|
||||
// Get the first row's cells to measure the widths
|
||||
const cells = table.rows[0].cells;
|
||||
|
||||
const handleColumnByIndex = async (i: number, waitForRenderArg: boolean = false) => {
|
||||
const done = plugins.smartpromise.defer();
|
||||
const cell = cells[i];
|
||||
|
||||
// Get computed width
|
||||
const width = window.getComputedStyle(cell).width;
|
||||
if (cell.textContent.includes('Actions')) {
|
||||
const neededWidth =
|
||||
this.dataActions.filter((actionArg) => actionArg.type.includes('inRow')).length * 36;
|
||||
cell.style.width = `${Math.max(neededWidth, 68)}px`;
|
||||
} else {
|
||||
cell.style.width = width;
|
||||
}
|
||||
if (waitForRenderArg) {
|
||||
requestAnimationFrame(() => {
|
||||
done.resolve();
|
||||
});
|
||||
await done.promise;
|
||||
}
|
||||
};
|
||||
|
||||
if (cells[cells.length - 1].textContent.includes('Actions')) {
|
||||
await handleColumnByIndex(cells.length - 1, true);
|
||||
}
|
||||
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
if (cells[i].textContent.includes('Actions')) {
|
||||
continue;
|
||||
}
|
||||
await handleColumnByIndex(i);
|
||||
}
|
||||
table.style.tableLayout = 'fixed';
|
||||
}
|
||||
|
||||
// compute helpers moved to ./data.ts
|
||||
|
||||
private toggleSort(col: Column<T>) {
|
||||
const key = String(col.key);
|
||||
if (this.sortKey !== key) {
|
||||
this.sortKey = key;
|
||||
this.sortDir = 'asc';
|
||||
} else {
|
||||
if (this.sortDir === 'asc') this.sortDir = 'desc';
|
||||
else if (this.sortDir === 'desc') {
|
||||
this.sortDir = null;
|
||||
this.sortKey = undefined;
|
||||
} else this.sortDir = 'asc';
|
||||
}
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('sortChange', {
|
||||
detail: { key: this.sortKey, dir: this.sortDir },
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private getAriaSort(col: Column<T>): 'none' | 'ascending' | 'descending' {
|
||||
if (String(col.key) !== this.sortKey || !this.sortDir) return 'none';
|
||||
return this.sortDir === 'asc' ? 'ascending' : 'descending';
|
||||
}
|
||||
|
||||
private renderSortIndicator(col: Column<T>) {
|
||||
if (String(col.key) !== this.sortKey || !this.sortDir) return html``;
|
||||
return html`<span style="margin-left:6px; opacity:0.7;">${this.sortDir === 'asc' ? '▲' : '▼'}</span>`;
|
||||
}
|
||||
|
||||
// filtering helpers
|
||||
public setFilterText(value: string) {
|
||||
const prev = this.filterText;
|
||||
this.filterText = value ?? '';
|
||||
if (prev !== this.filterText) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('filterChange', {
|
||||
detail: { text: this.filterText, columns: { ...this.columnFilters } },
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
public setColumnFilter(key: string, value: string) {
|
||||
this.columnFilters = { ...this.columnFilters, [key]: value };
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('filterChange', {
|
||||
detail: { text: this.filterText, columns: { ...this.columnFilters } },
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
// selection helpers
|
||||
private getRowId(row: T): string {
|
||||
if (this.rowKey) {
|
||||
if (typeof this.rowKey === 'function') return this.rowKey(row);
|
||||
return String((row as any)[this.rowKey]);
|
||||
}
|
||||
const key = row as any as object;
|
||||
if (!this._rowIdMap.has(key)) {
|
||||
this._rowIdMap.set(key, String(++this._rowIdCounter));
|
||||
}
|
||||
return this._rowIdMap.get(key);
|
||||
}
|
||||
|
||||
private isRowSelected(row: T): boolean {
|
||||
return this.selectedIds.has(this.getRowId(row));
|
||||
}
|
||||
|
||||
private toggleRowSelected(row: T) {
|
||||
const id = this.getRowId(row);
|
||||
if (this.selectionMode === 'single') {
|
||||
this.selectedIds.clear();
|
||||
this.selectedIds.add(id);
|
||||
} else if (this.selectionMode === 'multi') {
|
||||
if (this.selectedIds.has(id)) this.selectedIds.delete(id);
|
||||
else this.selectedIds.add(id);
|
||||
}
|
||||
this.emitSelectionChange();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private setRowSelected(row: T, checked: boolean) {
|
||||
const id = this.getRowId(row);
|
||||
if (this.selectionMode === 'single') {
|
||||
this.selectedIds.clear();
|
||||
if (checked) this.selectedIds.add(id);
|
||||
} else if (this.selectionMode === 'multi') {
|
||||
if (checked) this.selectedIds.add(id);
|
||||
else this.selectedIds.delete(id);
|
||||
}
|
||||
this.emitSelectionChange();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private areAllVisibleSelected(): boolean {
|
||||
const view: T[] = (this as any)._lastViewData || [];
|
||||
if (view.length === 0) return false;
|
||||
for (const r of view) {
|
||||
if (!this.selectedIds.has(this.getRowId(r))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private isVisibleSelectionIndeterminate(): boolean {
|
||||
const view: T[] = (this as any)._lastViewData || [];
|
||||
if (view.length === 0) return false;
|
||||
let count = 0;
|
||||
for (const r of view) {
|
||||
if (this.selectedIds.has(this.getRowId(r))) count++;
|
||||
}
|
||||
return count > 0 && count < view.length;
|
||||
}
|
||||
|
||||
private setSelectVisible(checked: boolean) {
|
||||
const view: T[] = (this as any)._lastViewData || [];
|
||||
if (checked) {
|
||||
for (const r of view) this.selectedIds.add(this.getRowId(r));
|
||||
} else {
|
||||
for (const r of view) this.selectedIds.delete(this.getRowId(r));
|
||||
}
|
||||
this.emitSelectionChange();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private emitSelectionChange() {
|
||||
const selectedIds = Array.from(this.selectedIds);
|
||||
const selectedRows = this.data.filter((r) => this.selectedIds.has(this.getRowId(r)));
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('selectionChange', {
|
||||
detail: { selectedIds, selectedRows },
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getActionsForType(typeArg: ITableAction['type'][0]) {
|
||||
const actions: ITableAction[] = [];
|
||||
for (const action of this.dataActions) {
|
||||
if (!action.type.includes(typeArg)) continue;
|
||||
actions.push(action);
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
async handleCellEditing(event: Event, itemArg: T, key: string) {
|
||||
await this.domtoolsPromise;
|
||||
const target = event.target as HTMLElement;
|
||||
const originalColor = target.style.color;
|
||||
target.style.color = 'transparent';
|
||||
const transformedItem = this.displayFunction(itemArg);
|
||||
const initialValue = ((transformedItem as any)[key] ?? (itemArg as any)[key] ?? '') as string;
|
||||
// Create an input element
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = initialValue;
|
||||
|
||||
const blurInput = async (blurArg = true, saveArg = false) => {
|
||||
if (blurArg) {
|
||||
input.blur();
|
||||
}
|
||||
if (saveArg) {
|
||||
itemArg[key] = input.value as any; // Convert string to T (you might need better type casting depending on your data structure)
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
input.remove();
|
||||
target.style.color = originalColor;
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
// When the input loses focus or the Enter key is pressed, update the data
|
||||
input.addEventListener('blur', () => {
|
||||
blurInput(false, false);
|
||||
});
|
||||
input.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
blurInput(true, true); // This will trigger the blur event handler above
|
||||
}
|
||||
});
|
||||
|
||||
// Replace the cell's content with the input
|
||||
target.appendChild(input);
|
||||
input.focus();
|
||||
}
|
||||
}
|
170
ts_web/elements/dees-table/lucene.ts
Normal file
170
ts_web/elements/dees-table/lucene.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import type { Column } from './types.js';
|
||||
|
||||
type FilterMode = 'table' | 'data';
|
||||
|
||||
export type RowPredicate<T> = (row: T) => boolean;
|
||||
|
||||
interface Term {
|
||||
field?: string; // if undefined, match across all fields
|
||||
value?: string; // lowercased string
|
||||
negate?: boolean;
|
||||
range?: { lower: string; upper: string; inclusive: boolean };
|
||||
}
|
||||
|
||||
interface Clause {
|
||||
terms: Term[]; // AND across terms
|
||||
}
|
||||
|
||||
interface LuceneQuery {
|
||||
clauses: Clause[]; // OR across clauses
|
||||
}
|
||||
|
||||
function stripQuotes(s: string): string {
|
||||
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
||||
return s.slice(1, -1);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function splitByOr(input: string): string[] {
|
||||
return input.split(/\s+OR\s+/i).map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function splitByAnd(input: string): string[] {
|
||||
return input.split(/\s+AND\s+/i).map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function parseTerm(raw: string): Term | null {
|
||||
if (!raw) return null;
|
||||
let negate = false;
|
||||
// handle NOT prefix or leading '-'
|
||||
const notMatch = raw.match(/^\s*(NOT\s+|-)\s*(.*)$/i);
|
||||
if (notMatch) {
|
||||
negate = true;
|
||||
raw = notMatch[2];
|
||||
}
|
||||
// range: field:[lower TO upper]
|
||||
const rangeMatch = raw.match(/^([^:\s]+)\s*:\s*\[(.*?)\s+TO\s+(.*?)\]$/i);
|
||||
if (rangeMatch) {
|
||||
return {
|
||||
field: rangeMatch[1],
|
||||
negate,
|
||||
range: { lower: stripQuotes(rangeMatch[2]).toLowerCase(), upper: stripQuotes(rangeMatch[3]).toLowerCase(), inclusive: true },
|
||||
};
|
||||
}
|
||||
// field:value (value may be quoted)
|
||||
const m = raw.match(/^([^:\s]+)\s*:\s*("[^"]*"|'[^']*'|[^"'\s]+)$/);
|
||||
if (m) {
|
||||
return { field: m[1], value: stripQuotes(m[2]).toLowerCase(), negate };
|
||||
}
|
||||
// plain term
|
||||
if (raw.length > 0) {
|
||||
return { value: stripQuotes(raw).toLowerCase(), negate };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseLucene(input: string): LuceneQuery | null {
|
||||
if (!input) return null;
|
||||
const clauses = splitByOr(input).map((clauseStr) => {
|
||||
const terms = splitByAnd(clauseStr)
|
||||
.map(parseTerm)
|
||||
.filter((t): t is Term => !!t && !!t.value);
|
||||
return { terms } as Clause;
|
||||
}).filter((c) => c.terms.length > 0);
|
||||
if (clauses.length === 0) return null;
|
||||
return { clauses };
|
||||
}
|
||||
|
||||
export function compileLucenePredicate<T>(
|
||||
input: string,
|
||||
mode: FilterMode,
|
||||
columns: Column<T>[]
|
||||
): RowPredicate<T> | null {
|
||||
const ast = parseLucene(input);
|
||||
if (!ast) return null;
|
||||
const colMap = new Map<string, Column<T>>(
|
||||
columns.map((c) => [String(c.key), c])
|
||||
);
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
|
||||
const coerce = (s: any) => {
|
||||
const str = String(s ?? '').toLowerCase();
|
||||
const num = Number(str);
|
||||
const date = Date.parse(str);
|
||||
if (!Number.isNaN(num) && str.trim() !== '') return { t: 'n' as const, v: num };
|
||||
if (!Number.isNaN(date)) return { t: 'd' as const, v: date };
|
||||
return { t: 's' as const, v: str };
|
||||
};
|
||||
const inRange = (val: any, lower: string, upper: string) => {
|
||||
const a = coerce(val);
|
||||
const lo = coerce(lower);
|
||||
const up = coerce(upper);
|
||||
// All strings: lexical compare
|
||||
if (a.t === 's' && lo.t === 's' && up.t === 's') {
|
||||
const av = a.v as string;
|
||||
return cmp(av, lo.v as string) >= 0 && cmp(av, up.v as string) <= 0;
|
||||
}
|
||||
// All numbers
|
||||
if (a.t === 'n' && lo.t === 'n' && up.t === 'n') {
|
||||
const av = a.v as number;
|
||||
return av >= (lo.v as number) && av <= (up.v as number);
|
||||
}
|
||||
// All dates (as numbers)
|
||||
if (a.t === 'd' && lo.t === 'd' && up.t === 'd') {
|
||||
const av = a.v as number;
|
||||
return av >= (lo.v as number) && av <= (up.v as number);
|
||||
}
|
||||
// Fallback: compare string forms
|
||||
const as = String(val ?? '').toLowerCase();
|
||||
return cmp(as, lower) >= 0 && cmp(as, upper) <= 0;
|
||||
};
|
||||
return (row: T) => {
|
||||
for (const clause of ast.clauses) {
|
||||
let clauseOk = true;
|
||||
for (const term of clause.terms) {
|
||||
let ok = false;
|
||||
if (term.range && term.field) {
|
||||
// range compare on field
|
||||
if (mode === 'data') {
|
||||
ok = inRange((row as any)[term.field], term.range.lower, term.range.upper);
|
||||
} else {
|
||||
const col = colMap.get(term.field);
|
||||
if (!col || col.hidden) { ok = false; } else {
|
||||
const val = col.value ? col.value(row) : (row as any)[col.key as any];
|
||||
ok = inRange(val, term.range.lower, term.range.upper);
|
||||
}
|
||||
}
|
||||
} else if (term.field && term.value != null) {
|
||||
if (mode === 'data') {
|
||||
const s = String((row as any)[term.field] ?? '').toLowerCase();
|
||||
ok = s.includes(term.value);
|
||||
} else {
|
||||
const col = colMap.get(term.field);
|
||||
if (!col || col.hidden === true) { ok = false; }
|
||||
else {
|
||||
const val = col.value ? col.value(row) : (row as any)[col.key as any];
|
||||
const s = String(val ?? '').toLowerCase();
|
||||
ok = s.includes(term.value);
|
||||
}
|
||||
}
|
||||
} else if (term.value != null) {
|
||||
// search across all visible/raw fields
|
||||
if (mode === 'data') {
|
||||
ok = Object.values(row as any).some((v) => String(v ?? '').toLowerCase().includes(term.value!));
|
||||
} else {
|
||||
ok = columns.some((col) => {
|
||||
if (col.hidden) return false;
|
||||
const val = col.value ? col.value(row) : (row as any)[col.key as any];
|
||||
const s = String(val ?? '').toLowerCase();
|
||||
return s.includes(term.value!);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (term.negate) ok = !ok;
|
||||
if (!ok) { clauseOk = false; break; }
|
||||
}
|
||||
if (clauseOk) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
}
|
429
ts_web/elements/dees-table/styles.ts
Normal file
429
ts_web/elements/dees-table/styles.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
import { cssManager, css, type CSSResult } from '@design.estate/dees-element';
|
||||
import { cssGeistFontFamily } from '../00fonts.js';
|
||||
|
||||
export const tableStyles: CSSResult[] = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mainbox {
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
|
||||
font-family: ${cssGeistFontFamily};
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
min-height: 64px;
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
}
|
||||
|
||||
.headingContainer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.heading {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.heading1 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.heading2 {
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.headingSeparation {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.headerAction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 45.1%)', 'hsl(0 0% 63.9%)')};
|
||||
background: transparent;
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.headerAction:hover {
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
|
||||
border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
|
||||
}
|
||||
|
||||
.headerAction dees-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.searchGrid {
|
||||
display: grid;
|
||||
grid-gap: 16px;
|
||||
grid-template-columns: 1fr max-content;
|
||||
padding: 16px 24px;
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 98%)', 'hsl(0 0% 3.9%)')};
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.searchGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* let search mode size to content (no forced width) */
|
||||
|
||||
.searchGrid.hidden {
|
||||
height: 0px;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
padding: 0px 24px;
|
||||
border-bottom-width: 0px;
|
||||
}
|
||||
|
||||
.tableScroll {
|
||||
/* enable horizontal scroll only when content exceeds width */
|
||||
overflow-x: auto;
|
||||
/* prevent vertical scroll inside the table container */
|
||||
overflow-y: hidden;
|
||||
/* avoid reserving extra space for classic scrollbars where possible */
|
||||
scrollbar-gutter: stable both-edges;
|
||||
}
|
||||
/* Hide horizontal scrollbar entirely when not using sticky header */
|
||||
:host(:not([sticky-header])) .tableScroll {
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
scrollbar-width: none; /* Firefox (hides both axes) */
|
||||
}
|
||||
:host(:not([sticky-header])) .tableScroll::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari */
|
||||
}
|
||||
/* In sticky-header mode, hide only the horizontal scrollbar in WebKit/Blink */
|
||||
:host([sticky-header]) .tableScroll::-webkit-scrollbar:horizontal {
|
||||
height: 0px;
|
||||
}
|
||||
:host([sticky-header]) .tableScroll {
|
||||
max-height: var(--table-max-height, 360px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
/* allow table to grow wider than container so actions column can stick */
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
caption-side: bottom;
|
||||
font-size: 14px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.noDataSet {
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')};
|
||||
}
|
||||
|
||||
thead {
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 9%)')};
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')};
|
||||
}
|
||||
:host([sticky-header]) thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
transition: background-color 0.15s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Default horizontal lines (bottom border only) */
|
||||
tbody tr {
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
}
|
||||
|
||||
tbody tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Full horizontal lines when enabled */
|
||||
:host([show-horizontal-lines]) tbody tr {
|
||||
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
}
|
||||
|
||||
:host([show-horizontal-lines]) tbody tr:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
:host([show-horizontal-lines]) tbody tr:last-child {
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1% / 0.5)', 'hsl(0 0% 14.9% / 0.5)')};
|
||||
}
|
||||
|
||||
/* Column hover effect for better traceability */
|
||||
td {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
td::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1% / 0.3)', 'hsl(0 0% 14.9% / 0.3)')};
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
td:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Grid mode - shows both vertical and horizontal lines */
|
||||
:host([show-grid]) th {
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-left: none;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
:host([show-grid]) td {
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-left: none;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
:host([show-grid]) th:first-child,
|
||||
:host([show-grid]) td:first-child {
|
||||
border-left: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
}
|
||||
|
||||
:host([show-grid]) tbody tr:first-child td {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
/* Sticky Actions column (right pinned) */
|
||||
thead th.actionsCol,
|
||||
tbody td.actionsCol {
|
||||
position: sticky;
|
||||
right: 0;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 3.9%)')};
|
||||
}
|
||||
thead th.actionsCol { z-index: 3; }
|
||||
tbody td.actionsCol {
|
||||
z-index: 1;
|
||||
box-shadow: -1px 0 0 0 ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
}
|
||||
|
||||
tbody tr.selected {
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 14.9%)')};
|
||||
}
|
||||
|
||||
tbody tr.hasAttachment {
|
||||
background: ${cssManager.bdTheme('hsl(142.1 76.2% 36.3% / 0.1)', 'hsl(142.1 76.2% 36.3% / 0.1)')};
|
||||
}
|
||||
|
||||
th {
|
||||
height: 48px;
|
||||
padding: 12px 24px;
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
:host([show-vertical-lines]) th {
|
||||
border-right: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px 24px;
|
||||
vertical-align: middle;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
|
||||
}
|
||||
|
||||
:host([show-vertical-lines]) td {
|
||||
border-right: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
}
|
||||
|
||||
th:first-child,
|
||||
td:first-child {
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
th:last-child,
|
||||
td:last-child {
|
||||
padding-right: 24px;
|
||||
}
|
||||
|
||||
:host([show-vertical-lines]) th:last-child,
|
||||
:host([show-vertical-lines]) td:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.innerCellContainer {
|
||||
position: relative;
|
||||
min-height: 24px;
|
||||
line-height: 24px;
|
||||
}
|
||||
td input {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
width: calc(100% - 40px);
|
||||
height: calc(100% - 8px);
|
||||
padding: 0 12px;
|
||||
outline: none;
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
border-radius: 6px;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
transition: all 0.15s ease;
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
}
|
||||
|
||||
td input:focus {
|
||||
border-color: ${cssManager.bdTheme('hsl(222.2 47.4% 51.2%)', 'hsl(217.2 91.2% 59.8%)')};
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(222.2 47.4% 51.2% / 0.2)', 'hsl(217.2 91.2% 59.8% / 0.2)')};
|
||||
}
|
||||
|
||||
/* filter row */
|
||||
thead tr.filtersRow th {
|
||||
padding: 8px 12px 12px 12px;
|
||||
}
|
||||
thead tr.filtersRow th input[type='text'] {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 9%)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 3.9%)', 'hsl(0 0% 98%)')};
|
||||
}
|
||||
.actionsContainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.action:hover {
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 14.9%)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||
}
|
||||
|
||||
.action:active {
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 11.8%)')};
|
||||
}
|
||||
|
||||
.action dees-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 52px;
|
||||
padding: 0 24px;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
|
||||
background: ${cssManager.bdTheme('hsl(210 40% 96.1%)', 'hsl(0 0% 9%)')};
|
||||
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')};
|
||||
}
|
||||
|
||||
.tableStatistics {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.footerActions .footerAction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
font-weight: 500;
|
||||
color: ${cssManager.bdTheme('hsl(215.4 16.3% 46.9%)', 'hsl(215 20.2% 65.1%)')};
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.footerActions .footerAction:hover {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')};
|
||||
}
|
||||
|
||||
.footerActions .footerAction dees-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
`,
|
||||
];
|
29
ts_web/elements/dees-table/types.ts
Normal file
29
ts_web/elements/dees-table/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { TemplateResult } from '@design.estate/dees-element';
|
||||
import type { TIconKey } from '../dees-icon.js';
|
||||
|
||||
export interface ITableActionDataArg<T> {
|
||||
item: T;
|
||||
table: any; // avoid circular typing with DeesTable; consumers rely on shape only
|
||||
}
|
||||
|
||||
export interface ITableAction<T = any> {
|
||||
name: string;
|
||||
iconName: TIconKey;
|
||||
useTableBehaviour?: 'upload' | 'cancelUpload' | 'none';
|
||||
type: ('inRow' | 'contextmenu' | 'doubleClick' | 'footer' | 'header' | 'preview' | 'keyCombination')[];
|
||||
actionRelevancyCheckFunc?: (itemArg: T) => boolean;
|
||||
actionFunc: (actionDataArg: ITableActionDataArg<T>) => Promise<any>;
|
||||
}
|
||||
|
||||
export interface Column<T = any> {
|
||||
key: keyof T | string;
|
||||
header?: string | TemplateResult;
|
||||
value?: (row: T) => any;
|
||||
renderer?: (value: any, row: T, ctx: { rowIndex: number; colIndex: number; column: Column<T> }) => TemplateResult | string;
|
||||
sortable?: boolean;
|
||||
/** whether this column participates in per-column quick filtering (default: true) */
|
||||
filterable?: boolean;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export type TDisplayFunction<T = any> = (itemArg: T) => Record<string, any>;
|
@@ -18,8 +18,8 @@ export * from './dees-chips.js';
|
||||
export * from './dees-contextmenu.js';
|
||||
export * from './dees-dataview-codebox.js';
|
||||
export * from './dees-dataview-statusobject.js';
|
||||
export * from './dees-dashboardgrid.js';
|
||||
export * from './dees-editor.js';
|
||||
export * from './dees-dashboardgrid/index.js';
|
||||
export * from './dees-editor/dees-editor.js';
|
||||
export * from './dees-editor-markdown.js';
|
||||
export * from './dees-editor-markdownoutlet.js';
|
||||
export * from './dees-form-submit.js';
|
||||
@@ -57,7 +57,7 @@ export * from './dees-speechbubble.js';
|
||||
export * from './dees-spinner.js';
|
||||
export * from './dees-statsgrid.js';
|
||||
export * from './dees-stepper.js';
|
||||
export * from './dees-table.js';
|
||||
export * from './dees-table/dees-table.js';
|
||||
export * from './dees-terminal.js';
|
||||
export * from './dees-toast.js';
|
||||
export * from './dees-updater.js';
|
||||
|
@@ -1,65 +0,0 @@
|
||||
# WYSIWYG Block Cleanup Status
|
||||
|
||||
## Overview
|
||||
This document tracks the cleanup of `dees-wysiwyg-block.ts` after migrating all block types to the new block handler architecture.
|
||||
|
||||
## Completed ✅
|
||||
All cleanup tasks have been successfully completed on 2025-06-26.
|
||||
|
||||
## Cleanup Tasks
|
||||
|
||||
### 1. ✅ Remove Block-Specific Styles (lines 101-219)
|
||||
- [x] Remove `.block.heading-1/2/3` styles → Now in `heading.block.ts`
|
||||
- [x] Remove `.block.quote` styles → Now in `quote.block.ts`
|
||||
- [x] Remove `.block.list` styles → Now in `list.block.ts`
|
||||
- [x] Remove `.block.paragraph` styles → Now in `paragraph.block.ts`
|
||||
|
||||
### 2. ✅ Remove Code Block Specific Logic
|
||||
- [x] Remove code block rendering in `renderBlockContent()` (lines 508-521)
|
||||
- [x] Remove all `type === 'code'` conditional branches
|
||||
- [x] Simplify element selection to not special-case code blocks
|
||||
|
||||
### 3. ✅ Remove List Block Specific Logic
|
||||
- [x] Remove `focusListItem()` method (lines 814-821)
|
||||
- [x] Remove list-specific handling in `getContent()` (lines 732-734)
|
||||
- [x] Remove list-specific handling in `setContent()` (lines 764-765)
|
||||
- [x] Remove list content rendering in `firstUpdated()` (line 479)
|
||||
|
||||
### 4. ✅ Remove getPlaceholder() Method
|
||||
- [x] Remove entire method (lines 538-553)
|
||||
- [x] Update renderBlockContent() to not use placeholders
|
||||
|
||||
### 5. ✅ Clean Up Excessive Empty Lines
|
||||
- [x] Remove consecutive blank lines throughout the file
|
||||
|
||||
### 6. ✅ Centralize nonEditableTypes
|
||||
- [x] Create a single source of truth for non-editable block types
|
||||
- [x] Remove duplicate arrays
|
||||
|
||||
### 7. ✅ Simplify Handler Delegation
|
||||
- [x] Keep handler delegation pattern but ensure consistency
|
||||
|
||||
### 8. ✅ Remove Unused Properties (if confirmed unused)
|
||||
- [x] Keep `contentInitialized` - still used for tracking
|
||||
- [x] Keep `blockElement` - used for caching
|
||||
- [x] Keep cursor tracking properties - used for selection
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Block Types Now Fully Handled by Handlers:
|
||||
1. **Text blocks**: paragraph, heading-1/2/3, quote, code, list
|
||||
2. **Media blocks**: image, youtube, attachment
|
||||
3. **Content blocks**: divider, markdown, html
|
||||
|
||||
### Remaining Responsibilities of dees-wysiwyg-block.ts:
|
||||
1. Shadow DOM container management
|
||||
2. Handler delegation for all operations
|
||||
3. Generic block wrapper styles
|
||||
4. Selection/cursor tracking
|
||||
5. Event listener setup (until fully delegated to handlers)
|
||||
|
||||
## Future Improvements
|
||||
- Consider moving all event handling to block handlers
|
||||
- Simplify the handler delegation pattern
|
||||
- Move generic block styles to a shared location
|
||||
- Consider removing the need for special-casing any block types
|
@@ -1,87 +0,0 @@
|
||||
# WYSIWYG Block Migration Status
|
||||
|
||||
## Overview
|
||||
This document tracks the progress of migrating all WYSIWYG blocks to the new block handler architecture.
|
||||
|
||||
## Migration Progress
|
||||
|
||||
### ✅ Phase 1: Architecture Foundation
|
||||
- Created block handler base classes and interfaces
|
||||
- Created block registry system
|
||||
- Created common block styles and utilities
|
||||
|
||||
### ✅ Phase 2: Divider Block
|
||||
- Simple non-editable block as proof of concept
|
||||
- See `phase2-summary.md` for details
|
||||
|
||||
### ✅ Phase 3: Paragraph Block
|
||||
- First text block with full editing capabilities
|
||||
- Established patterns for text selection, cursor tracking, and content splitting
|
||||
- See commit history for implementation details
|
||||
|
||||
### ✅ Phase 4: Heading Blocks
|
||||
- All three heading levels (h1, h2, h3) using unified handler
|
||||
- See `phase4-summary.md` for details
|
||||
|
||||
### ✅ Phase 5: Other Text Blocks
|
||||
- [x] Quote block - Completed with custom styling
|
||||
- [x] Code block - Completed with syntax highlighting, line numbers, and copy button
|
||||
- [x] List block - Completed with bullet and numbered list support
|
||||
|
||||
### 🔄 Phase 6: Media Blocks (In Progress)
|
||||
- [x] Image block - Completed with click upload, drag-drop, and base64 encoding
|
||||
- [x] YouTube block - Completed with URL parsing and video embedding
|
||||
- [ ] Attachment block
|
||||
|
||||
### 📋 Phase 7: Content Blocks (Planned)
|
||||
- [ ] Markdown block
|
||||
- [ ] HTML block
|
||||
|
||||
## Block Handler Status
|
||||
|
||||
| Block Type | Handler Created | Registered | Tested | Notes |
|
||||
|------------|----------------|------------|---------|-------|
|
||||
| divider | ✅ | ✅ | ✅ | Complete |
|
||||
| paragraph | ✅ | ✅ | ✅ | Complete |
|
||||
| heading-1 | ✅ | ✅ | ✅ | Using HeadingBlockHandler |
|
||||
| heading-2 | ✅ | ✅ | ✅ | Using HeadingBlockHandler |
|
||||
| heading-3 | ✅ | ✅ | ✅ | Using HeadingBlockHandler |
|
||||
| quote | ✅ | ✅ | ✅ | Complete with custom styling |
|
||||
| code | ✅ | ✅ | ✅ | Complete with highlighting, line numbers, copy |
|
||||
| list | ✅ | ✅ | ✅ | Complete with bullet/numbered support |
|
||||
| image | ✅ | ✅ | ✅ | Complete with upload, drag-drop support |
|
||||
| youtube | ✅ | ✅ | ✅ | Complete with URL parsing, video embedding |
|
||||
| attachment | ❌ | ❌ | ❌ | Phase 6 |
|
||||
| markdown | ❌ | ❌ | ❌ | Phase 7 |
|
||||
| html | ❌ | ❌ | ❌ | Phase 7 |
|
||||
|
||||
## Files Modified During Migration
|
||||
|
||||
### Core Architecture Files
|
||||
- `blocks/block.base.ts` - Base handler interface and class
|
||||
- `blocks/block.registry.ts` - Registry for handlers
|
||||
- `blocks/block.styles.ts` - Common styles
|
||||
- `blocks/index.ts` - Main exports
|
||||
- `wysiwyg.blockregistration.ts` - Registration of all handlers
|
||||
|
||||
### Handler Files Created
|
||||
- `blocks/content/divider.block.ts`
|
||||
- `blocks/text/paragraph.block.ts`
|
||||
- `blocks/text/heading.block.ts`
|
||||
- `blocks/text/quote.block.ts`
|
||||
- `blocks/text/code.block.ts`
|
||||
- `blocks/text/list.block.ts`
|
||||
- `blocks/media/image.block.ts`
|
||||
- `blocks/media/youtube.block.ts`
|
||||
|
||||
### Main Component Updates
|
||||
- `dees-wysiwyg-block.ts` - Updated to use registry pattern
|
||||
|
||||
## Next Steps
|
||||
1. Begin Phase 6: Media blocks migration
|
||||
- Start with image block (most common media type)
|
||||
- Implement YouTube block for video embedding
|
||||
- Create attachment block for file uploads
|
||||
2. Follow established patterns from existing handlers
|
||||
3. Test thoroughly after each migration
|
||||
4. Update documentation as blocks are completed
|
@@ -1,7 +0,0 @@
|
||||
* We don't use lit html logic, no event binding, no nothing, but only use static`` here to handle dom operations ourselves
|
||||
* We try to have separated concerns in different classes
|
||||
* We try to have clean concise and managable code
|
||||
* lets log whats happening, so if something goes wrong, we understand whats happening.
|
||||
* Read https://developer.mozilla.org/en-US/docs/Web/API/Selection/getComposedRanges
|
||||
* Read https://developer.mozilla.org/en-US/docs/Web/API/Selection/getComposedRanges
|
||||
* Make sure to hand over correct shodowroots.
|
Reference in New Issue
Block a user