Skip to content

Add Lit example implementation#1

Open
oddcelot wants to merge 23 commits intoferderer:mainfrom
oddcelot:lit
Open

Add Lit example implementation#1
oddcelot wants to merge 23 commits intoferderer:mainfrom
oddcelot:lit

Conversation

@oddcelot
Copy link
Copy Markdown

@oddcelot oddcelot commented May 7, 2026

Summary

  • Add a Lit 3 micro example demonstrating Texivia routing (locale-prefixed routes, parameterized paths, redirects, layout composition, 404 fallback).
  • Document the Lit integration in the root README.md with a worked <app-shell> snippet and the Light DOM constraint that makes click interception work.
  • Emit dist/texivia.d.ts from the publish build so TypeScript users get types out of the box.
  • Add lit and web-components to package keywords; add Lit to the framework support matrix.

Implementation notes

  • Router shape. Router<View> is parameterized over a render function: View = (params) => TemplateResult. Each route's view is the render fn the example swaps into a single root <app-shell>.
  • Light DOM is load-bearing, not stylistic. Texivia's click handler calls event.target.closest('a'). Shadow DOM retargets event.target to the host (<app-shell>), so closest('a') walks past the actual anchor. The example uses createRenderRoot() { return this } to keep <a> as the literal target. The README and in-code comments both spell this out.
  • Listener before start(). Routes without a handler dispatch the texivia event synchronously inside router.start(), so a late listener misses the initial event on deep-link loads.
  • Layouts as plain functions. mainLayout(locale, body) is a template function, not a LitElement — avoids <slot> ceremony in light DOM and adds no state of its own.
  • Initial locale seed. <app-shell> seeds params.locale from navigator.language.split('-')[0] so the first paint matches the '/' → '/<lang>/' redirect, eliminating a flash of wrong-locale links.
  • Login form is inert. @submit=${e => e.preventDefault()} instead of a real POST, since the example demos routing, not auth.

oddcelot and others added 23 commits May 5, 2026 21:18
Mirror examples/svelte/micro layout: package.json with file:../../..
texivia-router dep + lit ^3, tsconfig pair (app/node), vite config,
index.html entrypoint, shared app.css design tokens, favicon.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
app-header / app-footer mirror the Svelte+Vue micro examples.
Both render in light DOM (createRenderRoot returns this) so global
app.css applies and anchor clicks bubble to document for Texivia
interception. main-layout slots page content between them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Seven pages mirroring the Svelte/Vue micro examples: landing, login,
user-profile, about, imprint, contact, not-found. Each exports a
LitElement plus a render() function that the router map will hold as
the typed view value — same role component classes play in the
Svelte/Vue versions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
router.ts uses Router<View> where View = (params) => TemplateResult,
mirroring how Svelte/Vue store the component class itself. The render
fn pattern keeps the route map declarative and avoids pulling in
lit/static-html for tag-name dispatch.

app-shell.ts owns the texivia event subscription and re-renders by
swapping the @state-tracked view + params. Light DOM ensures global
styles apply and anchor clicks reach document for interception.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Light-DOM rendering ignores <slot> elements (slots only project in
shadow DOM), so children placed inside <main-layout> in the page
templates were rendered above the header instead of inside <main>.

Pages now build a body TemplateResult and pass it via .body, mirroring
the named-snippet pattern used in the Svelte micro example. Layout
keeps light DOM so the global app.css applies and anchor clicks bubble
to document for Texivia's interception.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Routes without a handler complete _navigate synchronously inside
start() and dispatch the texivia event before control returns. Adding
the listener after start() therefore lost the initial event on direct
deep-link loads (e.g. opening /en/users/42/profile would render the
default Landing view).

Reordering means the listener catches the very first dispatch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The root build doesn't emit dist/texivia.d.ts (vite lib mode without
a dts plugin), so a strict tsc pass over the example fails with
TS7016 — the same issue affects the Vue example's type-check step.
Match Vue's working build-only pattern: 'build' just runs vite,
'type-check' stays available as an opt-in script.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the Svelte/Vue micro examples which both check in their
lockfiles for reproducible installs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the Svelte/Vue example READMEs and documents the three
Lit-specific decisions: light DOM, listener-before-start ordering,
and body-as-template-prop instead of <slot>.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a Lit row to the framework comparison table and a full Lit 3
section between Svelte and Vue. Covers the typed Router<View>
pattern with render functions, the <app-shell> listener, and the
two non-obvious gotchas: light-DOM rendering and listener-before-start
ordering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Improves discoverability for Lit/web-components users searching npm
now that there's a first-party example.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The layout had no state, lifecycle, or events — only a locale prop
and a body prop that existed solely to work around <slot> being
inert in light DOM. Replacing the LitElement with a function lets
pages call mainLayout(locale, html\`...\`) directly. This removes
the .body reactive prop, drops a custom-element registration, and
eliminates the slot-vs-light-DOM footgun entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the body-prop note with a recommendation to use plain
template functions for stateless layouts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The package.json types field has always pointed at dist/texivia.d.ts
but the build only ran vite (lib mode emits no declarations), so any
consumer with strict TS hit TS7016 'implicitly has an any type'.
Chain 'tsc --emitDeclarationOnly' after vite — declaration is already
true in tsconfig, so no other config change is needed. Adds zero
runtime cost and fixes type-check failures in both example projects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Now that the root build emits dist/texivia.d.ts, the type-check pass
succeeds. Build runs tsc first, then vite, so type errors fail the
build instead of slipping through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Applies the repo's .prettierrc (semi, singleQuote, printWidth 120,
trailingComma es5) to all new files in examples/lit/micro. No
behavioral changes — purely whitespace, semicolons, and line wrapping
to match the existing project conventions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous comment said Light DOM lets <a> clicks "bubble to document"
— but composed click events bubble through shadow boundaries fine.
The actual constraint is event.target retargeting: with Shadow DOM,
target becomes the host (<app-shell>), so the router's document-level
closest('a') returns null and skips the navigation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lit renders the app-shell before router.start()'s async chain dispatches
the first texivia event, so the first paint uses whatever the @State
default is. The previous hardcoded 'en' caused a brief mismatch on
non-English browsers — header/footer links flickered as /en/ before the
'/' handler redirect resolved to e.g. /de-DE/.

Seeding from navigator.language aligns the initial paint with the
redirect target. Subtags are stripped to keep the seed a plain
language code; the handler still uses the full BCP-47 tag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The README's Lit 3 section claimed plain <a> tags are intercepted
automatically, but that's only true with Light DOM. With Shadow DOM
(LitElement's default), event.target retargets to the host and the
router's closest('a') misses the click.

Updates:
- Spell out the Light DOM constraint in the trailing summary so
  copy-pasters don't omit createRenderRoot() and silently break
  navigation.
- Reword the snippet's createRenderRoot() comment to match the example.
- Seed params.locale from navigator.language so the README snippet
  doesn't render empty-locale hrefs on first paint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The '/' handler redirected to /${navigator.language}/, preserving the
full BCP-47 tag (e.g. /de-DE/). The app-shell @State seed strips the
subtag, so a de-DE browser briefly rendered links with locale=de
before the redirect landed on /de-DE/ and re-rendered with locale=de-DE.

Stripping the subtag in the handler too keeps the seed and
post-redirect params identical, eliminating the second re-render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The form had method="POST" action="/${locale}/login" but no backend
exists. Submitting did a real navigation to a 404 — surprising for an
SPA example. Replaced with a noop @submit handler so the form stays
inert (the example demos routing, not auth).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three drifts after the recent fixes:

- Router snippet showed the bare navigator.language redirect; the
  example now strips the BCP-47 subtag.
- app-shell snippet seeded params.locale with a hardcoded 'en'; the
  example now seeds from navigator.language.split('-')[0].
- The Light DOM rationale claimed clicks "bubble to document" — they
  do, regardless of shadow DOM, since click is composed. The actual
  blocker is event.target retargeting: Texivia calls
  event.target.closest('a'), which in shadow DOM walks up from the
  host, not the anchor. Rewrote the paragraph and noted that a
  shadow-DOM-friendly setup would need the router to read
  event.composedPath() instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant