@wcstack/router 1.30.0 → 1.32.0

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/README.ja.md CHANGED
@@ -122,6 +122,58 @@
122
122
  | 属性 | 説明 |
123
123
  |------|------|
124
124
  | `basename` | サブフォルダのURLでルーティングする場合に、サブフォルダを指定。サブフォルダで動作させない場合は、指定不要 |
125
+ | `focus` | commit したナビゲーション後に適用するオプトインのフォーカスポリシー。`"heading"` でリーフ route 内容の最初の見出しへフォーカス。「アクセシビリティ契約」参照 |
126
+ | `announce` | オプトインのルート告知。`"title"` で commit 時点の `document.title` スナップショットを router 保有の live region へ書き込む。「アクセシビリティ契約」参照 |
127
+
128
+ #### state バインディング(wc-bindable)
129
+
130
+ `<wcs-router>` は live DOM に居る要素としてナビゲーション状態の全量を wc-bindable プロトコルで露出する。`@wcstack/state`(あるいは任意の binding core)は `data-wcs` 一つで配線できる:
131
+
132
+ ```html
133
+ <wcs-router data-wcs="path: path; typedParams: routeParams; searchParams: query;
134
+ routeName: routeName; navigateUrl: navigateUrl; replaceUrl: replaceUrl">
135
+ ```
136
+
137
+ | メンバー | 方向 | 説明 |
138
+ |------|------|------|
139
+ | `path` | output のみ | 現在のルートパス(basename スライス後)。`wcs-router:path-changed` を発火 |
140
+ | `params` | output のみ | マッチしたルートチェーンのマージ済みパラメータ(文字列、`Record<string, string>`)。fallback マッチ・初期化前は `{}` |
141
+ | `typedParams` | output のみ | 同パラメータの型変換済み値(`:id(int)` → `number`)。イベントは `params` と共有(detail は `{ params, typedParams }`) |
142
+ | `searchParams` | output のみ | 現在 URL のクエリ(`Record<string, string>`)。キー重複(`?tag=a&tag=b`)は **last-wins**、デコードは `URLSearchParams` に委ねる(`+` → 空白を含む)。クエリ無しは `{}`。`wcs-router:search-changed` を発火 |
143
+ | `routeName` | output のみ | 最深マッチルートの `name` 属性値。fallback マッチ時は fallback ルートの `name`(404 画面も `routeName` 分岐で書ける)。無名・初期化前は `""`。`wcs-router:route-name-changed` を発火 |
144
+ | `navigateUrl` | 書き込み面(null-idle transient) | ターゲットを書くと push 遷移。null は待機、文字列の書き込みで `navigate()` が起動し、完了後に自分で null へ戻る。null / `""` の書き込みは no-op |
145
+ | `replaceUrl` | 書き込み面(null-idle transient) | `navigateUrl` と完全同型の契約。ただし現在の履歴エントリを**置き換える** |
146
+ | `basename` | input | `basename` 属性のミラー |
147
+
148
+ コマンド `navigate(path)` / `replace(path)`(いずれも async)も宣言され、command-token プロトコルから起動できる。
149
+
150
+ output-only メンバーはバインド attach 時に**読まれ**、以後は変更イベントで流れる — 値は「読むもの」であり「待つもの」ではないので、router が最初のルートを解決した後に attach したバインドでも取りこぼしはない。
151
+
152
+ **発火規範**: commit されたナビゲーションでは、router はまず**全内部値をコミット**し、その後で `params-changed` → `route-name-changed` → `search-changed` → `path-changed` の順に、値が実際に変化したものだけを発火する。どのイベントのリスナーから要素プロパティを読んでも遷移後スナップショットの一貫した値が見える。`path` は最後に発火し「ナビゲーション完了」の信号を兼ねる。guard 拒否されたナビゲーションでは何も更新せず何も発火しない。
153
+
154
+ 露出オブジェクトは router が所有する **frozen スナップショット**(ナビゲーションごとに新しいオブジェクト、in-place 変異なし)。変異は throw する — 自分の state へコピーして使うこと。
155
+
156
+ **書き込み面の使い分け**:
157
+
158
+ - ページネーション・タブ(戻るボタンで戻りたい)→ `navigateUrl = "?page=2"`
159
+ - 検索ボックス・絞り込み(履歴を打鍵ごとに汚したくない)→ `replaceUrl = "?q=" + …`(高頻度入力には `<wcs-debounce>` を挟む)
160
+
161
+ **マルチ Router**: `params` / `routeName` は各 Router 自身のマッチを反映するが、ページの URL にクエリは 1 つしかない — どの Router 経由で書いてもページ全体のクエリが置き換わる。一方**読み取り面は per-Router**: Router は自分の `basename` 配下のナビゲーションを処理したときだけ `searchParams` を commit するので、その値は「その Router が最後に処理したナビゲーション時点のクエリ」である。
162
+
163
+ #### ナビゲーションターゲットのクエリ文字列
164
+
165
+ `navigate()` / `replace()` / `navigateUrl` / `replaceUrl` / `<wcs-link to>` は次を受理する:
166
+
167
+ | 形 | 意味 |
168
+ |------|------|
169
+ | `/path` | パス遷移。現在のクエリは**引き継がない**(引き継ぎたい場合は `searchParams` から組み立てる) |
170
+ | `/path?k=v` | パス遷移+クエリ指定 |
171
+ | `?k=v` | クエリのみ遷移:pathname は現在値を維持 |
172
+ | `?` | クエリの全消去(pathname 維持) |
173
+
174
+ basename 結合と pathname 正規化は pathname にのみ適用され、クエリとハッシュはそのまま再結合される(ハッシュは素通し — router はハッシュではルーティングしない)。クエリはルートマッチングに一切関与しない。
175
+
176
+ 同一パスへのクエリのみ遷移は **same-match** ナビゲーションになる:ルートガードは再実行されず(ガードが守るのはルートへの**進入**であり、クエリ変化は進入ではない)、ルート内容は再スタンプされず、view transition も依頼されず、再アナウンスもされず、フォーカス・スクロールは動かない(履歴の traverse ではブラウザのスクロール復元が従来どおり働く)。変わるのは `searchParams` と URL だけである。
125
177
 
126
178
  ### Route(wcs-route)
127
179
 
@@ -138,10 +190,10 @@
138
190
 
139
191
  | プロパティ | 説明 |
140
192
  |------|------|
141
- | `params` | マッチしたパラメータ(文字列)を取得 |
142
- | `typedParams` | マッチしたパラメータ(型変換済み)を取得 |
143
193
  | `guardHandler` | ガード判定関数を設定 |
144
194
 
195
+ > **`params` / `typedParams` はどこへ?** `<wcs-router>` にある — 「state バインディング(wc-bindable)」参照。パース後の route 要素は detached なコントローラであり live DOM に属さないため、`querySelector` では見つからず `data-wcs` でも結線できない。マッチ結果の観測面は router 要素である。
196
+
145
197
  ガード判定関数の型:
146
198
  `(toPath: string, fromPath: string) => boolean | Promise<boolean>`
147
199
 
@@ -201,15 +253,18 @@
201
253
 
202
254
  **値の取得**:
203
255
 
204
- ```javascript
205
- // ルート要素から取得
206
- const route = document.querySelector('wcs-route[path="/users/:userId(int)"]');
256
+ マッチ結果は `<wcs-router>` 要素に露出される(route 要素自体は detached なコントローラで、live DOM からは取得できない):
207
257
 
208
- // 文字列として取得
209
- console.log(route.params.userId); // "123"
258
+ ```html
259
+ <!-- 宣言的: 解析結果をそのまま state へバインド -->
260
+ <wcs-router data-wcs="typedParams: routeParams"></wcs-router>
261
+ ```
210
262
 
211
- // 型変換済みの値として取得
212
- console.log(route.typedParams.userId); // 123 (number)
263
+ ```javascript
264
+ // 命令的: router 要素から読む
265
+ const router = document.querySelector('wcs-router');
266
+ console.log(router.params.userId); // "123"
267
+ console.log(router.typedParams.userId); // 123 (number)
213
268
  ```
214
269
 
215
270
  **動作仕様**:
@@ -292,17 +347,28 @@ console.log(route.typedParams.userId); // 123 (number)
292
347
 
293
348
  | 属性 | 説明 |
294
349
  |------|------|
295
- | `to` | 遷移先の絶対ルートパスもしくはURL。`/`で始まる場合はルートパス(basenameが付与される)。それ以外は外部URLとして扱われる |
350
+ | `to` | 遷移先の絶対ルートパスもしくはURL。`/`で始まる場合はルートパス(basename は pathname にのみ付与され、`?クエリ` / `#ハッシュ` はそのまま温存される)。`?` で始まる場合は**クエリのみリンク**:href は「現在 pathname + 指定クエリ」で組み立てられ、ロケーション変更に追従する。それ以外は外部URLとして扱われる |
296
351
 
297
- **アクティブ状態**: 生成された `<a>` はパスが現在のロケーションと一致する場合に `active` クラスを受け取る。ナビゲーションイベント(`currententrychange`, `wcs:navigate`, `popstate`)で更新される。
352
+ **アクティブ状態**: 生成された `<a>` はパスが現在のロケーションと一致する場合に `active` クラスと、同じ事実の ARIA 表現である `aria-current="page"` を受け取る(スクリーンリーダーがナビゲーション内の現在地を読み上げられる)。比較は **pathname のみ**で行われ、どちら側のクエリも影響しない(`to="/products"` は `/products?page=2` でも active のまま。クエリのみリンクはそのページに居る間つねに active)。ナビゲーションイベント(`currententrychange`, `wcs:navigate`, `popstate`)で更新される。
298
353
 
299
354
  ```css
300
355
  /* アクティブなリンクのスタイル */
301
356
  a.active { font-weight: bold; color: blue; }
302
357
  ```
303
358
 
359
+ **属性の転送**: `<a>` の生成時に、すべての `aria-*` 属性と固定 5 名(`title` / `rel` / `target` / `download` / `hreflang`)をホストから anchor へコピーする。`to` / `style` / `class` は決して転送しない(ホストは `display:none` であり、`class` は `active` 契約を持つ)。接続後に追従するのは固定 5 名のみで、**動的な `aria-*` 変更は anchor に届かない** — `<wcs-link data-wcs="attr.aria-label: ...">` のような `data-wcs` バインドもコピー後にホストへ書くため届かない。`<wcs-link>` の `aria-*` は静的属性で書くこと。
360
+
361
+ **素の `<a>` について**: Navigation API のあるブラウザでは、basename 配下の素の `<a href="/about">` も SPA 遷移になる(router が intercept する)。フォールバックブラウザでは成立しない(SPA 経路は `<wcs-link>` の click ハンドラのみ)ため、推奨は `<wcs-link>` のまま。
362
+
304
363
  ## 自動バインディング (`data-bind`)
305
364
 
365
+ ルートパラメータの配送には行き先の異なる 2 つの機構がある:
366
+
367
+ | パラメータの行き先 | 使うもの |
368
+ |------|------|
369
+ | state へ(リアクティブ描画・派生値) | `<wcs-router>` の `typedParams` / `params` バインド — 「state バインディング(wc-bindable)」参照 |
370
+ | route 内の要素へ直接(state を使わないページ・汎用コンポーネント) | 下記の `data-bind` |
371
+
306
372
  `data-bind` 属性を持つ要素は、マッチしたルートパラメータを自動的に受け取る。4つのバインディングモードに対応:
307
373
 
308
374
  | `data-bind` の値 | ターゲット | 説明 |
@@ -349,6 +415,53 @@ bootstrapRouter({
349
415
  });
350
416
  ```
351
417
 
418
+ ## ルート遷移アニメーション
419
+
420
+ ルートの差し替えは素の `removeChild` / `insertBefore` なので、去っていくビューは自力では退場できない。ページに [`@wcstack/view-transition`](https://github.com/wcstack/wcstack/tree/main/packages/view-transition) を足すと、差し替えが View Transition の中で行われ、見た目は CSS で書ける。
421
+
422
+ ```html
423
+ <script type="module" src="https://esm.run/@wcstack/view-transition/auto"></script>
424
+ <wcs-view-transition for="router"></wcs-view-transition>
425
+
426
+ <style>
427
+ ::view-transition-old(root) { animation: fade-out 0.2s both; }
428
+ ::view-transition-new(root) { animation: fade-in 0.2s both; }
429
+ </style>
430
+ ```
431
+
432
+ ルータはガードを先に走らせ、hide/show の対だけを遷移へ渡す。await するガードが遷移を開きっぱなしにしないため。ページを最初に描く「最初のルート適用」は常に同期で行う —— 対比すべき旧ルートが無く、入場は @starting-style の担当だから。タグが無ければ何も変わらない(差し替えは同期のまま)。[docs/view-transition-design.ja.md](https://github.com/wcstack/wcstack/blob/main/docs/view-transition-design.ja.md) §7.1 参照。
433
+
434
+ ## サーバーサイドレンダリング(SSR)
435
+
436
+ `<wcs-router enable-ssr>` を付けると [`@wcstack/server`](https://github.com/wcstack/wcstack/tree/main/packages/server) の SSR に参加する: `renderToString({ url })` がリクエスト URL の初期ルートをサーバーで描画し、クライアント側 router は起動時にサーバー描画済み DOM を再描画せずに**採用(adopt)**する — 採用ノード上で state のバインディングは生きたまま。属性が無ければ router はサーバーで一切初期化されず、従来どおりクライアントで描画される(部分 CSR)。
437
+
438
+ - サーバー出力が現在の URL・ルート定義と検証で一致しない場合、クライアントは静かに通常のクライアント描画へフォールバックする。
439
+ - guard 付きルートはサーバーで描画されない — guard は認可点でありクライアントで実行される。outlet は空のまま配信される。
440
+ - `<wcs-layout>` を使うルートは採用時にクライアント描画へフォールバックする。
441
+ - `<wcs-link>` の anchor はサーバーで描画され(`active` / `aria-current` 付き)、クライアントが採用する。
442
+
443
+ サーバー側の設定は `@wcstack/server` の README、設計は [docs/ssr-router-design.md](https://github.com/wcstack/wcstack/blob/main/docs/ssr-router-design.md) を参照。
444
+
445
+ ## アクセシビリティ契約
446
+
447
+ router は、プラットフォームが既に正しく行うことについては、スクロールとフォーカスの扱いをブラウザへ委譲する。
448
+
449
+ **Navigation API 経路**(Chromium ほか対応ブラウザ): router は `event.intercept()` に仕様既定を明示的に書いて渡す — `scroll: "after-transition"` と `focusReset: "after-transition"`。
450
+
451
+ - push 遷移はページ先頭へスクロールし、traverse(戻る/進む)は以前のスクロール位置を復元する
452
+ - 遷移後、フォーカスは新しい内容の最初の `[autofocus]` 要素へ、無ければ `<body>` へ移る
453
+
454
+ これらは仕様の既定値であり、明示は委譲が意図であることの記録。どちらかを `"manual"` に変える変更はリファクタではなくこの契約の変更にあたる。
455
+
456
+ **フォールバック経路**(Navigation API の無いブラウザ): ナビゲーションは `history.pushState` + `popstate` リスナで動く。commit した push 遷移の後は router がページ先頭へスクロールし、Navigation API の既定と揃える。route guard に拒否された遷移ではスクロールしない。戻る/進むのスクロール復元はブラウザの `history.scrollRestoration`(既定 `auto`)の仕事なので、router は `popstate` では決してスクロールしない。
457
+
458
+ **オプトインのポリシー** — `<wcs-router focus="heading" announce="title">`。どちらも既定はオフで、属性が無ければ上記のブラウザ挙動がすべて。どちらも commit したナビゲーションの直後にだけ走り、最初のルート適用(ページロードはブラウザの担当)と guard 拒否されたナビゲーションでは決して動かない。
459
+
460
+ - `focus="heading"`: **リーフ** route が挿入した内容の最初の `h1`〜`h6` へフォーカスを移す(見出しに tabindex が無ければ `tabindex="-1"` を付与)。指定中は Navigation API 経路で `focusReset: "manual"` を渡し、ブラウザ既定のリセットとの二重処理を防ぐ。新しい内容に見出しが無ければ何もしない — オプトインするなら各ルートに見出しを置くこと。
461
+ - `announce="title"`: commit 時点の `document.title` のスナップショットを router 保有の live region(`role="status"`・視覚的にクリップ・`<wcs-router>` 直下・router ごとに 1 つ)へ書き込む。既知の制限: バインド title(`<title data-wcs>`)は commit 時点で古いことがあり、ナビゲーション外の title 変化は再読み上げされない。
462
+
463
+ 設計の記録は [docs/a11y-design.md](https://github.com/wcstack/wcstack/blob/main/docs/a11y-design.md) §3 を参照。
464
+
352
465
  ## パス仕様案(Router / Route / Link 共通)
353
466
 
354
467
  ### 用語
@@ -391,6 +504,37 @@ basename は **必ず次に正規化**する:
391
504
  * `"/app"` と `"/app/"` は **同じ意味**(アプリの root)
392
505
  * `"/app"` は `"/app"` または `"/app/..."` にのみ一致(`"/appX"` には一致しない)
393
506
 
507
+ ### 1.4 ロケールを basename に置く(多言語サイト)
508
+
509
+ `/en/…` と `/ja/…` で配るサイトでは、ロケールを**ルートパターンではなく basename** に置く。
510
+ ロケールが決まった時点で `<head>` の同期スクリプトから `<base href="/ja/">` を書けば、
511
+ router は解決順 1.2 で拾う。
512
+
513
+ これは好みの問題ではない。`/:lang` をルートパラメータにすると**言語切替が壊れる**。
514
+
515
+ > `<wcs-router>` は basename 配下の同一オリジンナビゲーションを、素の `<a>` クリックも
516
+ > 含めて**すべて** `intercept()` に渡す。ロケールが basename の内側にあると、他言語への
517
+ > リンクはクライアント側で処理される —— ページが再読み込みされないので、ロケールごとに
518
+ > 読み込んだもの(辞書モジュール・`Intl` のフォーマッタ)は再評価されず、**言語が
519
+ > 変わらないまま何も壊れて見えない**。
520
+
521
+ basename が `/ja` なら `/en/orders` へのリンクは `_isOwnPath` を外れ、router は intercept を
522
+ 辞退し、ブラウザが本物のナビゲーションを行う。「ただのリンクで切り替わる」のは basename の
523
+ **おかげ**である。
524
+
525
+ 副産物が 2 つある。
526
+
527
+ * **ルートパターンからロケールが消える** —— `path="/"`・`path="/about"`。アプリ内リンクも
528
+ ロケールを持たない(`<wcs-link to="/about">` が basename を前置する)
529
+ * **ページ上の URL をすべて絶対にする**こと。`<base>` が相対 URL の基準を変えるため
530
+
531
+ ロケールを持たない URL や未対応のロケールの修復も、同じ head スクリプトで `location.replace`
532
+ する。ルートガードには置けない —— guard の redirect 先は静的な `guard="…"` 属性なので、
533
+ 残りのパスを保てない。DOM 解析前に済ませれば描画もフェッチも無駄にならない。
534
+
535
+ 全体像は [`examples/router-i18n`](../../examples/router-i18n/)、辞書をリアクティブな state では
536
+ なく ES モジュールにした理由は [docs/i18n-design.md](../../docs/i18n-design.md) を参照。
537
+
394
538
  ---
395
539
 
396
540
  ## 2) internalPath の仕様
package/README.md CHANGED
@@ -122,6 +122,58 @@ Define routes and layout slots inside a child template tag. A direct child templ
122
122
  | Attribute | Description |
123
123
  |------|------|
124
124
  | `basename` | When routing in a subfolder URL, specify the subfolder. Not required if you don’t run in a subfolder. |
125
+ | `focus` | Opt-in focus policy applied after a committed navigation. `"heading"` focuses the first heading of the leaf route's content. See "Accessibility contract". |
126
+ | `announce` | Opt-in route announcement. `"title"` writes the commit-time `document.title` snapshot into the router-owned live region. See "Accessibility contract". |
127
+
128
+ #### State binding (wc-bindable)
129
+
130
+ `<wcs-router>` is the live-DOM element that exposes the whole navigation state over the wc-bindable protocol, so `@wcstack/state` (or any binding core) wires it with a single `data-wcs`:
131
+
132
+ ```html
133
+ <wcs-router data-wcs="path: path; typedParams: routeParams; searchParams: query;
134
+ routeName: routeName; navigateUrl: navigateUrl; replaceUrl: replaceUrl">
135
+ ```
136
+
137
+ | Member | Direction | Description |
138
+ |------|------|------|
139
+ | `path` | output only | Current route path (basename already sliced). Fires `wcs-router:path-changed`. |
140
+ | `params` | output only | Merged params of the matched route chain, as strings (`Record<string, string>`). `{}` on a fallback match or before initialization. |
141
+ | `typedParams` | output only | The same params, type-converted (`:id(int)` → `number`). Shares `wcs-router:params-changed` with `params` (the event detail is `{ params, typedParams }`). |
142
+ | `searchParams` | output only | Current URL query as `Record<string, string>`. Duplicate keys (`?tag=a&tag=b`) are **last-wins**; values are decoded by `URLSearchParams` (including `+` → space). `{}` when there is no query. Fires `wcs-router:search-changed`. |
143
+ | `routeName` | output only | `name` attribute of the deepest matched route. On a fallback match, the fallback route's `name` (so a 404 view can key off `routeName` too). `""` when unnamed or before initialization. Fires `wcs-router:route-name-changed`. |
144
+ | `navigateUrl` | write surface (null-idle transient) | Write a target to push-navigate. `null` means idle; writing a string starts `navigate()`, and the property resets itself to `null` when the navigation finishes. `null` / `""` writes are no-ops. |
145
+ | `replaceUrl` | write surface (null-idle transient) | Identical contract to `navigateUrl`, but the navigation **replaces** the current history entry. |
146
+ | `basename` | input | Mirrors the `basename` attribute. |
147
+
148
+ Commands `navigate(path)` and `replace(path)` (both async) are also declared, so they can be invoked through the command-token protocol.
149
+
150
+ Output-only members are **read** when a binding attaches and streamed through their change events afterwards — the value is read, not awaited, so a binding that attaches after the router already resolved its first route misses nothing.
151
+
152
+ **Firing contract**: on a committed navigation the router commits *all* internal values first and only then fires events, in the order `params-changed` → `route-name-changed` → `search-changed` → `path-changed`, each only when its value actually changed. Any listener that reads the element's properties sees the consistent post-navigation snapshot; `path` fires last and doubles as the "navigation finished" signal. A guard-rejected navigation updates nothing and fires nothing.
153
+
154
+ The exposed objects are **frozen snapshots** owned by the router: a new object per navigation, never mutated in place. Mutating them throws — copy into your own state instead.
155
+
156
+ **Choosing a write surface**:
157
+
158
+ - Pagination, tabs — the back button should step through them: `navigateUrl = "?page=2"`.
159
+ - Search boxes, filters — the history should not record every keystroke: `replaceUrl = "?q=" + …`, with `<wcs-debounce>` in front of high-frequency input.
160
+
161
+ **Multiple routers**: `params` / `routeName` reflect each router's own match, but the page URL has a single query string — a query written through *any* router replaces the query for the whole page. Reads, however, are per-router: a router commits `searchParams` only when it processes a navigation under its own `basename`, so its value is "the query as of the last navigation this router processed".
162
+
163
+ #### Query strings in navigation targets
164
+
165
+ `navigate()` / `replace()` / `navigateUrl` / `replaceUrl` / `<wcs-link to>` accept:
166
+
167
+ | Form | Meaning |
168
+ |------|------|
169
+ | `/path` | Path navigation. The current query is **not** carried over (assemble it from `searchParams` if you want to keep it). |
170
+ | `/path?k=v` | Path navigation with a query. |
171
+ | `?k=v` | Query-only navigation: the pathname keeps its current value. |
172
+ | `?` | Clears the query (pathname stays). |
173
+
174
+ `basename` joining and pathname normalization apply to the pathname only; query and hash are re-attached verbatim (the hash is passed through untouched — the router never routes on it). Queries never participate in route matching.
175
+
176
+ A query-only navigation lands on the same matched route (**same-match**): route guards do not re-run (guards protect route *entry*, and a query change is not an entry), the route content is not restamped, no view transition is requested, no announcement is made, and focus / scroll stay where they are (browser scroll restoration still applies when traversing history). Only `searchParams` — and the URL — change.
125
177
 
126
178
  ### Route (wcs-route)
127
179
 
@@ -138,10 +190,10 @@ Displays children when the route path matches. Match priority is static paths ov
138
190
 
139
191
  | Property | Description |
140
192
  |------|------|
141
- | `params` | Matched parameters (strings). |
142
- | `typedParams` | Matched parameters (converted types). |
143
193
  | `guardHandler` | Sets the guard decision function. |
144
194
 
195
+ > **Where are `params` / `typedParams`?** On `<wcs-router>` — see "State binding (wc-bindable)". After parsing, the route elements are detached controllers: they are not part of the live DOM, so they cannot be found with `querySelector` and cannot be bound with `data-wcs`. The router element is the observation surface for match results.
196
+
145
197
  Guard decision function type:
146
198
  `(toPath: string, fromPath: string) => boolean | Promise<boolean>`
147
199
 
@@ -201,15 +253,18 @@ By specifying types for path parameters, you can perform value validation and au
201
253
 
202
254
  **Retrieving Values**:
203
255
 
204
- ```javascript
205
- // Get from the route element
206
- const route = document.querySelector('wcs-route[path="/users/:userId(int)"]');
256
+ The match result is exposed on the `<wcs-router>` element (the route elements themselves are detached controllers and cannot be queried from the live DOM):
207
257
 
208
- // Get as string
209
- console.log(route.params.userId); // "123"
258
+ ```html
259
+ <!-- Declarative: bind the parsed result straight into state -->
260
+ <wcs-router data-wcs="typedParams: routeParams"></wcs-router>
261
+ ```
210
262
 
211
- // Get as typed value
212
- console.log(route.typedParams.userId); // 123 (number)
263
+ ```javascript
264
+ // Imperative: read from the router element
265
+ const router = document.querySelector('wcs-router');
266
+ console.log(router.params.userId); // "123"
267
+ console.log(router.typedParams.userId); // 123 (number)
213
268
  ```
214
269
 
215
270
  **Behavior**:
@@ -292,17 +347,28 @@ Link. Converted to an `<a>`, and the route path in the `to` attribute is convert
292
347
 
293
348
  | Attribute | Description |
294
349
  |------|------|
295
- | `to` | Destination path or URL. Paths starting with `/` are treated as internal paths (basename is prepended). Other values are treated as external URLs. |
350
+ | `to` | Destination path or URL. Paths starting with `/` are treated as internal paths (basename is prepended to the pathname; a `?query` / `#hash` suffix is kept as-is). A value starting with `?` is a **query-only** link: the href is assembled as "current pathname + that query" and tracks location changes. Other values are treated as external URLs. |
296
351
 
297
- **Active state**: The generated `<a>` receives the `active` class when its path matches the current location. Tracking is updated on navigation events (`currententrychange`, `wcs:navigate`, `popstate`).
352
+ **Active state**: The generated `<a>` receives the `active` class when its path matches the current location, and `aria-current="page"` alongside it — the same fact, expressed in ARIA, so screen readers announce the current page in navigation. The comparison uses the **pathname only** — queries on either side never affect it (so `to="/products"` stays active on `/products?page=2`, and a query-only link is active whenever you are on its page). Tracking is updated on navigation events (`currententrychange`, `wcs:navigate`, `popstate`).
298
353
 
299
354
  ```css
300
355
  /* Style active links */
301
356
  a.active { font-weight: bold; color: blue; }
302
357
  ```
303
358
 
359
+ **Attribute forwarding**: when the `<a>` is generated, all `aria-*` attributes plus five fixed names (`title`, `rel`, `target`, `download`, `hreflang`) are copied from the host to the anchor. `to`, `style`, and `class` are never forwarded (the host is `display:none`, and `class` carries the `active` contract). After connection, only the five fixed names keep tracking changes; **dynamic `aria-*` changes do not reach the anchor** — this includes `data-wcs` bindings such as `<wcs-link data-wcs="attr.aria-label: ...">`, which write to the host after the copy has happened. Write `aria-*` on `<wcs-link>` as static attributes.
360
+
361
+ **Plain `<a>` note**: in browsers with the Navigation API, a plain `<a href="/about">` under the basename also becomes an SPA navigation (the router intercepts it). This does not hold in fallback browsers, where only `<wcs-link>`'s click handler provides SPA navigation — so `<wcs-link>` remains the recommendation.
362
+
304
363
  ## Auto-Binding (`data-bind`)
305
364
 
365
+ Two mechanisms deliver route params, with different destinations:
366
+
367
+ | You want params… | Use |
368
+ |------|------|
369
+ | …in state (reactive rendering, derived values) | Bind `typedParams` / `params` on `<wcs-router>` — see "State binding (wc-bindable)" |
370
+ | …directly on elements inside the route (pages without state, generic components) | `data-bind` below |
371
+
306
372
  Elements with the `data-bind` attribute automatically receive matched route parameters. Four binding modes are available:
307
373
 
308
374
  | `data-bind` value | Target | Description |
@@ -349,6 +415,53 @@ bootstrapRouter({
349
415
  });
350
416
  ```
351
417
 
418
+ ## Route transition animations
419
+
420
+ Route swaps are a plain `removeChild` / `insertBefore` pair, so the outgoing view cannot animate out on its own. Adding [`@wcstack/view-transition`](https://github.com/wcstack/wcstack/tree/main/packages/view-transition) to the page makes the swap run inside a View Transition, which you then style in CSS:
421
+
422
+ ```html
423
+ <script type="module" src="https://esm.run/@wcstack/view-transition/auto"></script>
424
+ <wcs-view-transition for="router"></wcs-view-transition>
425
+
426
+ <style>
427
+ ::view-transition-old(root) { animation: fade-out 0.2s both; }
428
+ ::view-transition-new(root) { animation: fade-in 0.2s both; }
429
+ </style>
430
+ ```
431
+
432
+ The router runs its guards first and hands only the hide/show pair to the transition, so a guard that awaits does not hold the transition open. The first route application — the one that paints the page on load — is always synchronous: there is no previous route to animate against, and an entrance is `@starting-style`'s job. Without the tag nothing changes at all; the swap stays synchronous. See [docs/view-transition-design.md](https://github.com/wcstack/wcstack/blob/main/docs/view-transition-design.md) §7.1.
433
+
434
+ ## Server-side rendering (SSR)
435
+
436
+ `<wcs-router enable-ssr>` opts the router into [`@wcstack/server`](https://github.com/wcstack/wcstack/tree/main/packages/server)'s SSR: `renderToString({ url })` renders the initial route of the request URL on the server, and the client-side router **adopts** the server-rendered DOM on boot instead of re-rendering it — state bindings hydrated on those nodes stay live. Without the attribute the router never initializes on the server and the page renders client-side as usual (partial CSR).
437
+
438
+ - If the server output does not verify against the current URL and route definitions, the client silently falls back to normal client-side rendering.
439
+ - Guarded routes are never rendered on the server — a guard is an authorization point and runs client-side; the outlet is served empty.
440
+ - Routes using `<wcs-layout>` fall back to client-side rendering on adoption.
441
+ - `<wcs-link>` renders its anchor on the server (with `active` / `aria-current`) and adopts it on the client.
442
+
443
+ See the `@wcstack/server` README for the server-side setup and [docs/ssr-router-design.md](https://github.com/wcstack/wcstack/blob/main/docs/ssr-router-design.md) (ja) for the design.
444
+
445
+ ## Accessibility contract
446
+
447
+ The router delegates scroll and focus handling to the browser wherever the platform already does the right thing.
448
+
449
+ **Navigation API path** (Chromium and other supporting browsers): the router calls `event.intercept()` with the spec defaults written out explicitly — `scroll: "after-transition"` and `focusReset: "after-transition"`:
450
+
451
+ - a push navigation scrolls to the top; a traverse (back/forward) restores the previous scroll position;
452
+ - after the transition, focus moves to the first `[autofocus]` element of the new content, or to `<body>` when there is none.
453
+
454
+ These are the specification defaults; writing them out records the delegation as intent. Changing either to `"manual"` is a change to this contract, not a refactor.
455
+
456
+ **Fallback path** (browsers without the Navigation API): navigation runs through `history.pushState` plus a `popstate` listener. After a committed push navigation the router scrolls to the top, matching the Navigation API default; a navigation rejected by a route guard does not scroll. Back/forward scroll restoration is the browser's `history.scrollRestoration` (default `auto`), so the router never scrolls on `popstate`.
457
+
458
+ **Opt-in policies** — `<wcs-router focus="heading" announce="title">`. Both default to off; with no attribute, the browser behavior above is all there is. Both run right after a committed navigation, never on the first route application (page load belongs to the browser), and never on a guard-rejected navigation.
459
+
460
+ - `focus="heading"`: moves focus to the first `h1`–`h6` of the **leaf** route's inserted content, adding `tabindex="-1"` when the heading has none. While set, the router passes `focusReset: "manual"` on the Navigation API path so the browser's reset does not double-handle. If the new content has no heading, nothing happens — give every route a heading when you opt in.
461
+ - `announce="title"`: writes the commit-time `document.title` snapshot into a router-owned live region (`role="status"`, visually clipped, direct child of `<wcs-router>`, one per router). Known limits: a bound title (`<title data-wcs>`) may still be stale at commit, and a title change outside navigation is not re-announced.
462
+
463
+ See [docs/a11y-design.md](https://github.com/wcstack/wcstack/blob/main/docs/a11y-design.md) §3 for the design record.
464
+
352
465
  ## Path Specification (Router / Route / Link)
353
466
 
354
467
  ### Terminology
@@ -391,6 +504,43 @@ Examples:
391
504
  * `"/app"` and `"/app/"` are **the same** (app root)
392
505
  * `"/app"` matches only `"/app"` or `"/app/..."` (does not match `"/appX"`)
393
506
 
507
+ ### 1.4 basename as the locale segment (multilingual sites)
508
+
509
+ For a site served at `/en/…` and `/ja/…`, put the locale **in the basename**,
510
+ not in a route pattern. Write `<base href="/ja/">` from a synchronous `<head>`
511
+ script once the locale is known, and the router picks it up through resolution
512
+ order 1.2.
513
+
514
+ This is not a stylistic preference — a `/:lang` route parameter breaks the
515
+ language switch:
516
+
517
+ > `<wcs-router>` hands **every** same-origin navigation under its basename to
518
+ > `intercept()`, plain `<a>` clicks included. With the locale inside the
519
+ > basename, a link to another language is handled client-side: the page never
520
+ > reloads, so anything the app loaded per-locale (a dictionary module, `Intl`
521
+ > formatters) is never re-evaluated and **the language silently does not
522
+ > change** — no error, nothing visibly broken.
523
+
524
+ With basename `/ja`, a link to `/en/orders` falls outside `_isOwnPath`, the
525
+ router declines to intercept, and the browser performs a real navigation. The
526
+ "just a link" language switch works *because* of the basename.
527
+
528
+ Two consequences worth having:
529
+
530
+ * **Route patterns carry no locale** — `path="/"`, `path="/about"`. In-app links
531
+ stay locale-free too (`<wcs-link to="/about">` prepends the basename).
532
+ * **Every URL on the page must be absolute**, since `<base>` now changes what
533
+ relative URLs resolve against.
534
+
535
+ Repairing a URL that carries no locale, or an unknown one, belongs in that same
536
+ head script (`location.replace`) rather than a route guard: a guard's redirect
537
+ target is the static `guard="…"` attribute, so it cannot preserve the rest of
538
+ the path. Doing it before the DOM is parsed also wastes no render and no fetch.
539
+
540
+ See [`examples/router-i18n`](../../examples/router-i18n/) for the whole shape,
541
+ and [docs/i18n-design.md](../../docs/i18n-design.md) for why the dictionary is
542
+ an ES module rather than reactive state.
543
+
394
544
  ---
395
545
 
396
546
  ## 2) internalPath specification