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
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:
@@ -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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user