@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;
|
|
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
|
+
"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": "
|
|
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"}
|