@leavittsoftware/web 11.3.0 → 11.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -31,6 +31,8 @@ When bumping `@leavittsoftware/web` in a downstream project, read every entry **
31
31
  - `AppRoute` / `PageRoute` / `RedirectRoute` imports from `titanium/helpers/route`
32
32
  - App-shell `#routes` tables and `before` handlers on page routes
33
33
  - `'page' in route` / `'redirect' in route` discriminators used to collect or execute matches
34
+ - **`window.navigation.navigate(`** anywhere inside `#route`, a route `before` handler, or a home-routing helper — this is the renderer-crash pattern below
35
+ - **`event.canIntercept`** / `event.navigationType === 'reload'` guard bodies hand-rolled in `#onNavigate`
34
36
  - `md-menu-item[inert]` style overrides / `--md-menu-item-leading-icon-color` on single-select subclasses
35
37
 
36
38
  **Adopt when upgrading to this version:**
@@ -38,6 +40,28 @@ When bumping `@leavittsoftware/web` in a downstream project, read every entry **
38
40
  - **`titanium-multi-select-base`** — new generic multi select combobox: selected items render as chips inside the field and the search input lives in the popover above the selectable items. Replaces the `titanium-chip-multi-select` + separate single-select + add-button pattern for "pick several from a list" inputs. Use it directly with `.items` (local fuse.js search) and optional `.sections` grouping, or extend it and override `onInputChanged` / `showSuggestions` for remote search — same hook names as `titanium-single-select-base`. Fires `selected-changed`; read the new value from `event.target.selected`.
39
41
  - **Middleware + halt pipeline** — `AppRoute` now includes `MiddlewareRoute` (`pattern` + required `before`, no `page`/`redirect`). Matching destinations run **all** matching middleware (and page `before` handlers) in declaration order; handlers continue by default and may return `ROUTE_HALT` / `'halt'` (`RouteHandlerResult`) to stop the pipeline without rendering. Rework app routers from “first match wins immediately” to the two-pass collect-then-execute pattern (see skeleton.leavitt.com `develop` / leavittbook `my-app.ts`). Prefer importing `ROUTE_HALT` from `titanium/helpers/route` instead of the string literal.
40
42
  - **`titanium-single-select-base` shaped menu tokens** — shaped open menus now set `--md-menu-item-leading-icon-color` / `--md-menu-item-trailing-icon-color` to `inverse-on-surface` (alongside the existing label/supporting-text tokens). Inert section-header `md-menu-item` styling (spacing + shaped inverse colors) lives on the base — remove duplicate `md-menu-item[inert]` / leading-icon color overrides from subclasses once on this build.
43
+ - **`getInterceptableUrl` + `resolveRoute` — required for any app with `redirect` routes.** Resolving a redirect with `window.navigation.navigate()` **crashes the renderer process**. A navigation started during boot, or from inside an intercept handler, produces a `NavigateEvent` whose handler settles once it is no longer the ongoing event, tripping `CHECK_EQ(this, window->navigation()->ongoing_navigate_event_)` in Blink's `NavigateEvent::ReactDone` (`navigate_event.cc`). The tab dies with `STATUS_BREAKPOINT`; it is not catchable from JS. Confirmed on Chrome 151 and the two prior stable builds.
44
+
45
+ `resolveRoute(routes, url)` follows redirects by rewriting the address bar with `history.replaceState` instead, and returns `{ url, matches }` where `matches` contains only middleware and page routes for the final URL. `getInterceptableUrl(event)` returns the destination URL to intercept or `null`, and encapsulates the intercept guards. The two coordinate through module-private state so the router does not intercept its own rewrite — `replaceState` dispatches a `navigate` event, and intercepting it starts a second, racing routing pass that reaches the same CHECK. **Use both together; do not hand-roll either half.**
46
+
47
+ ```ts
48
+ #onNavigate = (event: NavigateEvent) => {
49
+ const url = getInterceptableUrl(event);
50
+ if (!url) return;
51
+ event.intercept({ handler: () => this.#route(url) });
52
+ };
53
+
54
+ async #route(requestedUrl: URL) {
55
+ const { url, matches } = resolveRoute(this.#routes, requestedUrl);
56
+ if (!matches.some(({ route }) => 'page' in route)) return this.#showErrorPage();
57
+ for (const { route, params } of matches) {
58
+ if ((await route.before?.(params, url)) === ROUTE_HALT) return;
59
+ if ('page' in route) return this.#changePage(route.page, route.import);
60
+ }
61
+ }
62
+ ```
63
+
64
+ **Behaviour note:** a redirect means the intermediate URL is not real, so middleware matching it no longer runs — only the final URL's routes execute. An unresolvable redirect cycle (10 hops) returns no matches, which callers should treat as a 404.
41
65
 
42
66
  **Renamed / API changes:**
43
67
 
@@ -45,6 +69,7 @@ When bumping `@leavittsoftware/web` in a downstream project, read every entry **
45
69
  - `AppRoute` = `MiddlewareRoute | PageRoute | RedirectRoute`
46
70
  - `PageRoute.before` return type is now `RouteHandlerResult | Promise<RouteHandlerResult>` (may return `ROUTE_HALT` / `'halt'`)
47
71
  - New exports: `MiddlewareRoute`, `RouteHandlerResult`, `ROUTE_HALT`
72
+ - New exports from `titanium/helpers/route`: `getInterceptableUrl`, `resolveRoute`, and types `RouteMatch<T>`, `ResolvedRoute`
48
73
 
49
74
  ### 10.0.0
50
75
 
@@ -44,6 +44,14 @@ let LeavittAppContentContainer = class LeavittAppContentContainer extends LitEle
44
44
  scrollbar-color: var(--md-sys-color-surface-container-highest) transparent;
45
45
  border-radius: 28px;
46
46
  }
47
+
48
+ /* The host squares off in drawer mode, so the scroller has to as well — its
49
+ rounded overflow clip is otherwise visible at the page's top corners once
50
+ there is enough content to scroll. Safari does not like nested selectors on
51
+ host, so this stays flat. */
52
+ :host([main-menu-position='drawer']) scroll-container {
53
+ border-radius: 0;
54
+ }
47
55
  `,
48
56
  ]; }
49
57
  render() {
@@ -1 +1 @@
1
- {"version":3,"file":"app-main-content-container.js","sourceRoot":"","sources":["app-main-content-container.ts"],"names":[],"mappings":";AAAA,OAAO,kEAAkE,CAAC;AAE1E,OAAO,EAAE,UAAU,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,KAAK,CAAC;AAC5C,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AACnE,OAAO,EAAE,uBAAuB,EAAE,MAAM,uCAAuC,CAAC;AAChF,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAGhC,IAAM,0BAA0B,GAAhC,MAAM,0BAA2B,SAAQ,UAAU;IAAnD;;QAEwC,6CAAsC,IAAI,CAAC;QAGjF,qBAAgB,GAAW,MAAM,CAAC;IAuC3C,CAAC;IA3CqC,kCAAwC;IAAxC,IAAA,eAAe,qDAAyB;IAAxC,IAAA,eAAe,2DAAyB;IAC/B,sCAA2C;IAA3C,IAAA,mBAAmB,yDAAwB;IAA3C,IAAA,mBAAmB,+DAAwB;aAKjF,WAAM,GAAG;QACd,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;KA2BF;KACF,AA7BY,CA6BX;IAEF,MAAM;QACJ,OAAO,IAAI,CAAA;yFAC0E,IAAI,CAAC,mBAAmB;;KAE5G,CAAC;IACJ,CAAC;;AA1CmC;IAAnC,KAAK,CAAC,kBAAkB,CAAC;iEAAkD;AAC/B;IAA5C,QAAQ,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC;qEAA6D;AAGjF;IAFN,OAAO,CAAC,EAAE,OAAO,EAAE,uBAAuB,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC;IAC9D,QAAQ,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,oBAAoB,EAAE,CAAC;oEAClC;AAL9B,0BAA0B;IADtC,aAAa,CAAC,oCAAoC,CAAC;GACvC,0BAA0B,CA4CtC"}
1
+ {"version":3,"file":"app-main-content-container.js","sourceRoot":"","sources":["app-main-content-container.ts"],"names":[],"mappings":";AAAA,OAAO,kEAAkE,CAAC;AAE1E,OAAO,EAAE,UAAU,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,KAAK,CAAC;AAC5C,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AACnE,OAAO,EAAE,uBAAuB,EAAE,MAAM,uCAAuC,CAAC;AAChF,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAGhC,IAAM,0BAA0B,GAAhC,MAAM,0BAA2B,SAAQ,UAAU;IAAnD;;QAEwC,6CAAsC,IAAI,CAAC;QAGjF,qBAAgB,GAAW,MAAM,CAAC;IA+C3C,CAAC;IAnDqC,kCAAwC;IAAxC,IAAA,eAAe,qDAAyB;IAAxC,IAAA,eAAe,2DAAyB;IAC/B,sCAA2C;IAA3C,IAAA,mBAAmB,yDAAwB;IAA3C,IAAA,mBAAmB,+DAAwB;aAKjF,WAAM,GAAG;QACd,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAmCF;KACF,AArCY,CAqCX;IAEF,MAAM;QACJ,OAAO,IAAI,CAAA;yFAC0E,IAAI,CAAC,mBAAmB;;KAE5G,CAAC;IACJ,CAAC;;AAlDmC;IAAnC,KAAK,CAAC,kBAAkB,CAAC;iEAAkD;AAC/B;IAA5C,QAAQ,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC;qEAA6D;AAGjF;IAFN,OAAO,CAAC,EAAE,OAAO,EAAE,uBAAuB,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC;IAC9D,QAAQ,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,oBAAoB,EAAE,CAAC;oEAClC;AAL9B,0BAA0B;IADtC,aAAa,CAAC,oCAAoC,CAAC;GACvC,0BAA0B,CAoDtC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavittsoftware/web",
3
- "version": "11.3.0",
3
+ "version": "11.4.1",
4
4
  "license": "Apache-2.0",
5
5
  "description": "",
6
6
  "files": [
@@ -42,5 +42,5 @@
42
42
  "url": "https://github.com/LeavittSoftware/titanium-elements/issues"
43
43
  },
44
44
  "homepage": "https://github.com/LeavittSoftware/titanium-elements/#readme",
45
- "gitHead": "ab05ee189c2c85baa247e7d5460e74ef42ce1441"
45
+ "gitHead": "dc6b74566e849d9501a0212dee9c59a29b2ac56c"
46
46
  }
@@ -26,4 +26,44 @@ export type RedirectRoute = {
26
26
  redirect: string | ((params: Record<string, string>) => string);
27
27
  };
28
28
  export type AppRoute = MiddlewareRoute | PageRoute | RedirectRoute;
29
+ /** A matched route together with the params pulled out of its pattern. */
30
+ export type RouteMatch<T extends AppRoute = AppRoute> = {
31
+ route: T;
32
+ params: Record<string, string>;
33
+ };
34
+ /** The URL to route, after redirects, and the routes to execute for it. */
35
+ export type ResolvedRoute = {
36
+ url: URL;
37
+ matches: RouteMatch<MiddlewareRoute | PageRoute>[];
38
+ };
39
+ /**
40
+ * The destination URL when the app router should take this navigation over, or `null`
41
+ * to leave it to the browser.
42
+ *
43
+ * Use in a `navigate` listener:
44
+ *
45
+ * ```ts
46
+ * const url = getInterceptableUrl(event);
47
+ * if (!url) return;
48
+ * event.intercept({ handler: () => this.#route(url) });
49
+ * ```
50
+ */
51
+ export declare function getInterceptableUrl(event: NavigateEvent): URL | null;
52
+ /**
53
+ * Follows any redirect routes for `url` by rewriting the address bar, then returns the
54
+ * final URL along with the routes to execute for it — matching middleware in declaration
55
+ * order, ending at the first page route. Redirect routes are never returned; they are
56
+ * already resolved.
57
+ *
58
+ * Redirects deliberately do **not** call `navigation.navigate()`. A navigation started
59
+ * during boot, or from inside an intercept handler, produces a `NavigateEvent` whose
60
+ * handler settles once it is no longer the ongoing event, tripping
61
+ * `CHECK_EQ(this, window->navigation()->ongoing_navigate_event_)` in Blink's
62
+ * `NavigateEvent::ReactDone` — which kills the renderer process, not just the navigation.
63
+ *
64
+ * Because a redirect means "this URL is not real", middleware matching an intermediate
65
+ * URL does not run; only the final URL's routes are returned. An unresolvable redirect
66
+ * cycle yields no matches, which callers should treat as a 404.
67
+ */
68
+ export declare function resolveRoute(routes: AppRoute[], url: URL): ResolvedRoute;
29
69
  //# sourceMappingURL=route.d.ts.map
@@ -3,4 +3,91 @@
3
3
  * Prefer this constant over the `'halt'` string when writing `before` handlers.
4
4
  */
5
5
  export const ROUTE_HALT = 'halt';
6
+ /** Guards against a redirect cycle spinning the tab forever. */
7
+ const MAX_REDIRECT_HOPS = 10;
8
+ /**
9
+ * Set only while `resolveRoute` rewrites the address bar, so `getInterceptableUrl`
10
+ * can tell the router's own rewrite apart from a real navigation. Module-private on
11
+ * purpose: the two halves of this must never be separated by a consumer.
12
+ */
13
+ let isRewritingUrl = false;
14
+ /**
15
+ * The destination URL when the app router should take this navigation over, or `null`
16
+ * to leave it to the browser.
17
+ *
18
+ * Use in a `navigate` listener:
19
+ *
20
+ * ```ts
21
+ * const url = getInterceptableUrl(event);
22
+ * if (!url) return;
23
+ * event.intercept({ handler: () => this.#route(url) });
24
+ * ```
25
+ */
26
+ export function getInterceptableUrl(event) {
27
+ // A redirect rewrite from resolveRoute — whoever triggered it is already routing.
28
+ if (isRewritingUrl) {
29
+ return null;
30
+ }
31
+ if (!event.canIntercept || event.hashChange || event.downloadRequest !== null) {
32
+ return null;
33
+ }
34
+ // Dev-server live reload and user refresh must fetch fresh modules — don't SPA-handle reloads.
35
+ if (event.navigationType === 'reload') {
36
+ return null;
37
+ }
38
+ const url = new URL(event.destination.url);
39
+ return url.origin === location.origin ? url : null;
40
+ }
41
+ /**
42
+ * Follows any redirect routes for `url` by rewriting the address bar, then returns the
43
+ * final URL along with the routes to execute for it — matching middleware in declaration
44
+ * order, ending at the first page route. Redirect routes are never returned; they are
45
+ * already resolved.
46
+ *
47
+ * Redirects deliberately do **not** call `navigation.navigate()`. A navigation started
48
+ * during boot, or from inside an intercept handler, produces a `NavigateEvent` whose
49
+ * handler settles once it is no longer the ongoing event, tripping
50
+ * `CHECK_EQ(this, window->navigation()->ongoing_navigate_event_)` in Blink's
51
+ * `NavigateEvent::ReactDone` — which kills the renderer process, not just the navigation.
52
+ *
53
+ * Because a redirect means "this URL is not real", middleware matching an intermediate
54
+ * URL does not run; only the final URL's routes are returned. An unresolvable redirect
55
+ * cycle yields no matches, which callers should treat as a 404.
56
+ */
57
+ export function resolveRoute(routes, url) {
58
+ for (let hop = 0; hop <= MAX_REDIRECT_HOPS; hop++) {
59
+ const matches = [];
60
+ let redirect = null;
61
+ for (const route of routes) {
62
+ const match = route.pattern.exec({ pathname: url.pathname });
63
+ if (!match) {
64
+ continue;
65
+ }
66
+ const params = match.pathname.groups;
67
+ if ('redirect' in route) {
68
+ redirect = typeof route.redirect === 'function' ? route.redirect(params) : route.redirect;
69
+ break; // terminal
70
+ }
71
+ matches.push({ route, params });
72
+ if ('page' in route) {
73
+ break; // terminal — first matching page wins
74
+ }
75
+ // middleware entry — keep collecting
76
+ }
77
+ if (redirect === null) {
78
+ return { url, matches };
79
+ }
80
+ url = new URL(redirect, location.origin);
81
+ // replaceState dispatches a navigate event synchronously; the flag keeps the router
82
+ // from intercepting its own rewrite and starting a second, racing routing pass.
83
+ isRewritingUrl = true;
84
+ try {
85
+ history.replaceState({}, '', url.pathname + url.search + url.hash);
86
+ }
87
+ finally {
88
+ isRewritingUrl = false;
89
+ }
90
+ }
91
+ return { url, matches: [] };
92
+ }
6
93
  //# sourceMappingURL=route.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"route.js","sourceRoot":"","sources":["route.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,MAAe,CAAC"}
1
+ {"version":3,"file":"route.js","sourceRoot":"","sources":["route.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,MAAe,CAAC;AA0C1C,gEAAgE;AAChE,MAAM,iBAAiB,GAAG,EAAE,CAAC;AAE7B;;;;GAIG;AACH,IAAI,cAAc,GAAG,KAAK,CAAC;AAE3B;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,mBAAmB,CAAC,KAAoB;IACtD,kFAAkF;IAClF,IAAI,cAAc,EAAE,CAAC;QACnB,OAAO,IAAI,CAAC;IACd,CAAC;IACD,IAAI,CAAC,KAAK,CAAC,YAAY,IAAI,KAAK,CAAC,UAAU,IAAI,KAAK,CAAC,eAAe,KAAK,IAAI,EAAE,CAAC;QAC9E,OAAO,IAAI,CAAC;IACd,CAAC;IACD,+FAA+F;IAC/F,IAAI,KAAK,CAAC,cAAc,KAAK,QAAQ,EAAE,CAAC;QACtC,OAAO,IAAI,CAAC;IACd,CAAC;IACD,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC;IAC3C,OAAO,GAAG,CAAC,MAAM,KAAK,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC;AACrD,CAAC;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,YAAY,CAAC,MAAkB,EAAE,GAAQ;IACvD,KAAK,IAAI,GAAG,GAAG,CAAC,EAAE,GAAG,IAAI,iBAAiB,EAAE,GAAG,EAAE,EAAE,CAAC;QAClD,MAAM,OAAO,GAA8C,EAAE,CAAC;QAC9D,IAAI,QAAQ,GAAkB,IAAI,CAAC;QAEnC,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;YAC3B,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,GAAG,CAAC,QAAQ,EAAE,CAAC,CAAC;YAC7D,IAAI,CAAC,KAAK,EAAE,CAAC;gBACX,SAAS;YACX,CAAC;YACD,MAAM,MAAM,GAAG,KAAK,CAAC,QAAQ,CAAC,MAAgC,CAAC;YAC/D,IAAI,UAAU,IAAI,KAAK,EAAE,CAAC;gBACxB,QAAQ,GAAG,OAAO,KAAK,CAAC,QAAQ,KAAK,UAAU,CAAC,CAAC,CAAC,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,QAAQ,CAAC;gBAC1F,MAAM,CAAC,WAAW;YACpB,CAAC;YACD,OAAO,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,CAAC;YAChC,IAAI,MAAM,IAAI,KAAK,EAAE,CAAC;gBACpB,MAAM,CAAC,sCAAsC;YAC/C,CAAC;YACD,qCAAqC;QACvC,CAAC;QAED,IAAI,QAAQ,KAAK,IAAI,EAAE,CAAC;YACtB,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,CAAC;QAC1B,CAAC;QAED,GAAG,GAAG,IAAI,GAAG,CAAC,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC;QACzC,oFAAoF;QACpF,gFAAgF;QAChF,cAAc,GAAG,IAAI,CAAC;QACtB,IAAI,CAAC;YACH,OAAO,CAAC,YAAY,CAAC,EAAE,EAAE,EAAE,EAAE,GAAG,CAAC,QAAQ,GAAG,GAAG,CAAC,MAAM,GAAG,GAAG,CAAC,IAAI,CAAC,CAAC;QACrE,CAAC;gBAAS,CAAC;YACT,cAAc,GAAG,KAAK,CAAC;QACzB,CAAC;IACH,CAAC;IAED,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC;AAC9B,CAAC"}