Lit 3 custom elements — <entity-list>, <entity-detail>, <entity-form> — built on
@prometheus-ags/entity-graph-core. Framework-agnostic; works
anywhere you can include a <script type="module">.
Custom element
└── EntityListController / EntityDetailController / EntityFormController
└── graphStore (entity-graph-core, vanilla Zustand)
└── fetchEntity / fetchList / engine (entity-graph-core)
The controllers implement Lit 3's ReactiveController interface, subscribing to the
core Zustand graph store and calling requestUpdate() on the host element when relevant
slices change. No graph logic is reimplemented here — all fetch / retry / dedup / GC
logic lives in entity-graph-core.
pnpm add @prometheus-ags/entity-graph-web-components lit @prometheus-ags/entity-graph-core
@prometheus-ags/entity-graph-core is a required peer: install it explicitly so the application owns the one compatible graph instance. The Web Components binding must not install a private core copy. See the binding singleton contract.
<entity-list><entity-list entity-type="Invoice" id="invoice-list"></entity-list>
<script type="module">
import "@prometheus-ags/entity-graph-web-components";
import { registerEntityTransport, makeRestTransport } from "@prometheus-ags/entity-graph-core";
registerEntityTransport("Invoice", makeRestTransport({ baseUrl: "/api/invoices" }));
const el = document.querySelector("#invoice-list");
el.configure({
queryKey: ["invoices"],
fetch: (params) => fetch("/api/invoices").then(r => r.json()),
normalize: (raw) => ({ id: raw.id, data: raw }),
});
el.addEventListener("entity-list-loaded", (e) => {
console.log("Loaded", e.detail.items.length, "invoices");
});
</script>
Attributes: entity-type (required), loading-text, empty-text
Events: entity-list-loaded, entity-list-error
Slots: default (item markup), loading, empty, load-more, error
<entity-detail><entity-detail entity-type="Invoice" entity-id="inv-123" id="detail">
<!-- Projected markup is shown once entity is available -->
<p id="title"></p>
</entity-detail>
<script type="module">
import "@prometheus-ags/entity-graph-web-components";
const el = document.querySelector("#detail");
el.configure({
fetch: (id) => fetch(`/api/invoices/${id}`).then(r => r.json()),
normalize: (raw) => raw,
});
el.addEventListener("entity-loaded", (e) => {
document.querySelector("#title").textContent = e.detail.entity.title;
});
</script>
Attributes: entity-type, entity-id, loading-text, not-found-text
Events: entity-loaded, entity-not-found, entity-error
<entity-form><entity-form entity-type="Invoice" entity-id="inv-123" id="inv-form">
<input id="title-input" name="title" />
<div slot="actions">
<button id="save-btn">Save</button>
<button id="delete-btn">Delete</button>
</div>
</entity-form>
<script type="module">
import "@prometheus-ags/entity-graph-web-components";
import { graphStore } from "@prometheus-ags/entity-graph-core";
const el = document.querySelector("#inv-form");
el.configure({
fetch: (id) => fetch(`/api/invoices/${id}`).then(r => r.json()),
normalize: (raw) => raw,
onSave: async (buf) => {
const saved = await fetch(`/api/invoices/${buf.id}`, {
method: "PUT",
body: JSON.stringify(buf),
headers: { "Content-Type": "application/json" },
}).then(r => r.json());
graphStore.getState().upsertEntity("Invoice", saved.id, saved);
},
onDelete: async (id) => {
await fetch(`/api/invoices/${id}`, { method: "DELETE" });
graphStore.getState().removeEntity("Invoice", id);
graphStore.getState().removeIdFromAllLists("Invoice", id);
},
});
document.querySelector("#title-input").addEventListener("input", (e) => {
el.setField("title", e.target.value);
});
document.querySelector("#save-btn").addEventListener("click", () => el.save());
document.querySelector("#delete-btn").addEventListener("click", () => el.deleteEntity());
el.addEventListener("entity-form-saved", () => console.log("Saved!"));
el.addEventListener("entity-form-error", (e) => console.error(e.detail.message));
</script>
Attributes: entity-type, entity-id, loading-text, saving-text
Events: entity-form-saved, entity-form-deleted, entity-form-error, entity-form-dirty
Slots: default (form fields), loading, actions, error
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { EntityListController } from "@prometheus-ags/entity-graph-web-components";
@customElement("my-invoice-list")
class MyInvoiceList extends LitElement {
readonly #list = new EntityListController<RawInvoice, Invoice>(this, "Invoice", {
queryKey: ["invoices", { status: "open" }],
fetch: (params) => api.listInvoices(params),
normalize: (raw) => ({ id: raw.id, data: raw }),
});
render() {
const { items, isLoading, hasNextPage } = this.#list;
if (isLoading && items.length === 0) return html`<p>Loading…</p>`;
return html`
<ul>
${items.map((inv) => html`<li>${inv.title} — $${inv.amount}</li>`)}
</ul>
${hasNextPage
? html`<button @click=${() => this.#list.loadMore()}>Load more</button>`
: ""}
`;
}
}
pnpm --filter @prometheus-ags/entity-graph-web-components test
pnpm --filter @prometheus-ags/entity-graph-web-components build