fix(lit-components): fix rendering and interaction issues in force-graph, html-table, and draggable-dom
Build and Deploy / build-and-deploy (push) Has been cancelled

- lit-force-graph: Refactored for instance reuse, better lifecycle management, and added dynamic imports for AR/VR modes.
- lit-html-table: Improved visual design, readability of headers, and fixed reactivity in editable mode.
- lit-draggable-dom: Fixed clipping issues by switching to relative/absolute positioning and resolved a panning speed bug caused by CSS variable inheritance.
This commit is contained in:
2026-05-17 00:41:14 -07:00
parent bbdbeb9788
commit b9e42c3ef4
253 changed files with 2555 additions and 4471 deletions
@@ -0,0 +1,11 @@
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("menu-button")
export class MenuButton extends LitElement {
static styles = css``;
render() {
return html` <button @click=${() => alert('Menu Toggle')}>Menu</button> `;
}
}
@@ -0,0 +1,261 @@
import { html, css, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
@customElement("generated-app")
export class GeneratedApp extends LitElement {
static styles = css`
main {
width: 100%;
height: 100%;
}
progress {
position: absolute;
bottom: 0;
width: calc(100% - 1rem);
z-index: 1;
left: 0.5rem;
right: 0.5rem;
}
`;
@property() hash = 'true';
@property() base = '/';
@state() loading = false;
@property() route = this.getCurrentRoute();
@state() child = document.createElement('main');
dataCache = new Map<string, any>();
components: Map<string, Route> = new Map([
["/404", {
component: "unknown-route",
loadData: async () => null,
loadImport: () => import("./pages/404.js"),
}],
["/custom/not/nested/route", {
component: "custom-route",
loadData: async () => null,
loadImport: () => import("./pages/custom.not.nested.route.js"),
}],
["/dashboard/account/:id", {
component: "account-details",
loadData: async () => {
const {loader} = await import("./pages/dashboard/account/:id.js");
return loader;
},
loadImport: () => import("./pages/dashboard/account/:id.js"),
}],
["/dashboard/account/", {
component: "account-info",
loadData: async () => null,
loadImport: () => import("./pages/dashboard/account/index.js"),
}],
["/dashboard/account", {
component: "account-module",
loadData: async () => null,
loadImport: () => import("./pages/dashboard/account.js"),
}],
["/dashboard/", {
component: "dashboard-default",
loadData: async () => null,
loadImport: () => import("./pages/dashboard/index.js"),
}],
["/dashboard/overview", {
component: "overview-module",
loadData: async () => null,
loadImport: () => import("./pages/dashboard/overview.js"),
}],
["/dashboard", {
component: "dashboard-module",
loadData: async () => null,
loadImport: () => import("./pages/dashboard.js"),
}],
["/", {
component: "app-module",
loadData: async () => null,
loadImport: () => import("./pages/index.js"),
}],
["", {
component: "root-module",
loadData: async () => null,
loadImport: () => import("./pages/root.js"),
}],
["/settings/admin", {
component: "admin-settings",
loadData: async () => null,
loadImport: () => import("./pages/settings/admin.js"),
}],
["/settings/", {
component: "settings-default",
loadData: async () => null,
loadImport: () => import("./pages/settings/index.js"),
}],
["/settings", {
component: "settings-module",
loadData: async () => null,
loadImport: () => import("./pages/settings.js"),
}],
]);
firstUpdated() {
window.addEventListener("hashchange", () => {
const oldRoute = this.route;
this.route = this.getCurrentRoute();
this.updateTree(oldRoute);
});
this.updateTree();
}
render() {
return html` ${this.child} `;
}
refresh() {
return this.updateTree();
}
private async updateTree(oldRoute?:string) {
this.checkForIndex();
if (oldRoute) {
// TODO: Get delta between old and new route
}
const loadingElem = document.createElement("progress");
this.child.appendChild(loadingElem);
const tree = await this.renderTree();
// Remove children
while (this.child.firstChild) {
this.child.removeChild(this.child.firstChild);
}
// Add new children
if (tree) this.child.appendChild(tree);
this.requestUpdate();
}
private async renderTree() {
let _route = this.route;
const args = this.getArgsForRoute(_route);
const elements: Element[] = [];
if (_route !== "/") {
while (_route.length > 0) {
const newChild = await this.getComponent(_route, args);
if (!newChild && _route === this.route) {
const noChild = await this.getComponent("/404", args);
if (noChild) elements.push(noChild);
break;
}
elements.push(newChild!);
const parts = _route.split("/");
parts.pop();
_route = parts.join("/");
if (_route === "/") break;
}
} else if (_route === "/") {
const indexChild = await this.getComponent("/", args);
if (indexChild) elements.push(indexChild);
} else {
const noChild = await this.getComponent("/404", args);
if (noChild) elements.push(noChild);
}
const rootChild = await this.getComponent("", args);
if (rootChild) elements.push(rootChild);
let idx = elements.length - 1;
while (idx >= 1) {
const parent = elements[idx];
const child = elements[idx - 1];
if (child && parent) {
parent.appendChild(child);
}
idx--;
}
return elements.pop();
}
private async getComponent(
path: string,
args: RegExpMatchArray | null
) {
const applyArgs = (value: string, apply: boolean, data?: any) => {
const elem = document.createElement(value);
if (apply && args?.groups) {
for (const [key, value] of Object.entries(args.groups)) {
elem.setAttribute(key, value);
}
}
if (data) (elem as any).data = data;
return elem;
};
for (const [key, value] of Array.from(this.components.entries())) {
const hasArgs = path.match(fixRegex(key)) !== null;
if (key === path || path.match(fixRegex(key)) !== null) {
const cacheKey = `${path}:${value.component}`;
if (this.dataCache.has(cacheKey)) {
const data = this.dataCache.get(cacheKey)!;
await value.loadImport();
return applyArgs(value.component, hasArgs, data);
}
const getLoader = await value.loadData();
if (getLoader) {
const componentData = await getLoader(this.route, args ? Object(args)['groups'] : {});
this.dataCache.set(cacheKey, componentData);
await value.loadImport();
return applyArgs(value.component, hasArgs, componentData);
}
await value.loadImport();
return applyArgs(value.component, hasArgs);
}
}
return null;
}
private getArgsForRoute(route: string): RegExpMatchArray | null {
for (const key of Array.from(this.components.keys())) {
const regMatch = route.match(fixRegex(key));
if (regMatch !== null) return regMatch;
}
return null;
}
private checkForIndex() {
if (this.route === "" || this.route === "/") location.hash = "#/";
if (this.route.endsWith("/")) return;
const indexArgs = this.getArgsForRoute(`${this.route}/`);
if (indexArgs !== null) {
location.hash = `#${this.route}/`;
}
}
private getCurrentRoute() {
let route = "/";
if (this.hash === "true" && window.location.hash.length > 0) {
route = window.location.hash.slice(1);
} else if (this.hash === "false") {
const baseUrl = this.getAttribute("base") ?? "";
route = window.location.pathname.slice(baseUrl.length);
}
console.debug(`current route: ${route}`);
return route;
}
}
function fixRegex(route: string): RegExp {
const variableRegex = "[a-zA-Z0-9_-]+";
const nameWithParameters = route.replace(
new RegExp(`:(${variableRegex})`),
(match) => {
const groupName = match.slice(1);
return `(?<${groupName}>[a-zA-Z0-9_\\-.,:;+*^%$@!]+)`;
}
);
return new RegExp(`^${nameWithParameters}$`);
}
interface Route {
component: string;
loadImport: () => Promise<any>;
loadData: () => Promise<RouteLoader | null>;
}
type RouteLoader = (
route: string,
args: { [key: string]: any }
) => Promise<any>;
@@ -0,0 +1,13 @@
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("unknown-route")
export class UnknownRoute extends LitElement {
static styles = css``;
render() {
return html` <main>
<header>404</header>
</main>`;
}
}
@@ -0,0 +1,13 @@
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("custom-route")
export class CustomRoute extends LitElement {
static styles = css``;
render() {
return html` <main>
<header>Custom</header>
</main>`;
}
}
@@ -0,0 +1,35 @@
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
import '../components/menu-button.js';
@customElement("dashboard-module")
export class DashboardModule extends LitElement {
static styles = css`
header {
height: 40px;
background-color: orange;
color: white;
display: flex;
flex-direction: row;
align-items: center;
padding-left: 10px;
padding-right: 10px;
justify-content: space-between;
}
`;
render() {
return html`<main>
<header>
<menu-button></menu-button>
<span class="title">Dashboard</span>
<nav>
<a href="#/dashboard/overview">Overview</a>
<a href="#/dashboard/account/">Account</a>
</nav>
</header>
<section><slot></slot></section>
</main> `;
}
}
@@ -0,0 +1,11 @@
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("account-module")
export class AccountModule extends LitElement {
static styles = css``;
render() {
return html`<section><slot></slot></section>`;
}
}
@@ -0,0 +1,33 @@
import { html, css, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
export async function loader(
route: string,
args: { [key: string]: any }
): Promise<AccountData> {
await new Promise((resolve) => setTimeout(resolve, 1000));
const id = args["id"]!;
return {
id,
route,
name: "Name: " + id,
};
}
@customElement("account-details")
export class AccountDetails extends LitElement {
static styles = css``;
@property({ type: String }) id = "";
@property({ type: Object }) data!: AccountData;
render() {
return html`<section>${this.data.name}</section>`;
}
}
interface AccountData {
id: string;
route: string;
name: string;
}
@@ -0,0 +1,22 @@
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("account-info")
export class AccountInfo extends LitElement {
static styles = css`
article {
padding: 16px;
}
`;
render() {
return html`<article>
<h3>Account Info</h3>
<ul>
<li><a href="#/dashboard/account/1">User 1</a></li>
<li><a href="#/dashboard/account/2">User 2</a></li>
<li><a href="#/dashboard/account/3">User 3</a></li>
</ul>
</article>`;
}
}
@@ -0,0 +1,11 @@
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("dashboard-default")
export class DashboardDefault extends LitElement {
static styles = css``;
render() {
return html`<section>Default Dashboard</section>`;
}
}
@@ -0,0 +1,11 @@
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("overview-module")
export class OverviewModule extends LitElement {
static styles = css``;
render() {
return html`<section>Overview</section>`;
}
}
@@ -0,0 +1,24 @@
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("app-module")
export class AppModule extends LitElement {
static styles = css`
header {
height: 40px;
background-color: navy;
color: white;
display: flex;
flex-direction: row;
align-items: center;
padding-left: 10px;
}
`;
render() {
return html` <main>
<header>App Base</header>
<slot></slot>
</main>`;
}
}
@@ -0,0 +1,36 @@
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("root-module")
export class RootModule extends LitElement {
static styles = css`
main {
display: flex;
flex-direction: row;
height: 100vh;
width: 100%;
}
aside {
display: flex;
flex-direction: column;
background-color: whitesmoke;
padding: 8px;
}
section {
flex: 1;
}
`;
render() {
return html`
<main>
<aside>
<a href="#/">Home</a>
<a href="#/dashboard">Dashboard</a>
<a href="#/settings">Settings</a>
</aside>
<section><slot></slot></section>
</main>
`;
}
}
@@ -0,0 +1,31 @@
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
import "../components/menu-button.js";
@customElement("settings-module")
export class SettingsModule extends LitElement {
static styles = css`
header {
height: 40px;
background-color: black;
color: white;
display: flex;
flex-direction: row;
align-items: center;
padding-left: 10px;
justify-content: space-between;
}
`;
render() {
return html` <main>
<header>
<menu-button></menu-button>
<span class="title">Settings</span>
<div></div>
</header>
<slot></slot>
</main>`;
}
}
@@ -0,0 +1,11 @@
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("admin-settings")
export class AdminSettings extends LitElement {
static styles = css``;
render() {
return html`Admin Settings`;
}
}
@@ -0,0 +1,11 @@
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("settings-default")
export class SettingsDefault extends LitElement {
static styles = css``;
render() {
return html`<section>Default Settings</section>`;
}
}
@@ -0,0 +1,20 @@
import { describe, expect, test } from "vitest";
import "./generated-app";
describe("Lit File-Based Router Component", () => {
test("registers generated-app custom element successfully", () => {
expect(customElements.get("generated-app")).toBeDefined();
});
test("mounts router application container successfully", async () => {
document.body.innerHTML = "<generated-app></generated-app>";
await window.happyDOM.whenAsyncComplete();
const element = document.body.querySelector("generated-app");
expect(element).toBeDefined();
expect(element?.shadowRoot).toBeDefined();
// Await lazy route imports to complete before ending test and tearing down Happy DOM context
await new Promise((resolve) => setTimeout(resolve, 150));
});
});