@furystack/shades 12.2.5 → 12.4.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/esm/components/lazy-load.d.ts +2 -0
  3. package/esm/components/lazy-load.d.ts.map +1 -1
  4. package/esm/components/lazy-load.js +8 -3
  5. package/esm/components/lazy-load.js.map +1 -1
  6. package/esm/components/lazy-load.spec.js +59 -1
  7. package/esm/components/lazy-load.spec.js.map +1 -1
  8. package/esm/components/nested-router.d.ts +53 -0
  9. package/esm/components/nested-router.d.ts.map +1 -1
  10. package/esm/components/nested-router.js +35 -10
  11. package/esm/components/nested-router.js.map +1 -1
  12. package/esm/components/nested-router.spec.js +267 -19
  13. package/esm/components/nested-router.spec.js.map +1 -1
  14. package/esm/index.d.ts +1 -0
  15. package/esm/index.d.ts.map +1 -1
  16. package/esm/index.js +1 -0
  17. package/esm/index.js.map +1 -1
  18. package/esm/services/index.d.ts +2 -0
  19. package/esm/services/index.d.ts.map +1 -1
  20. package/esm/services/index.js +2 -0
  21. package/esm/services/index.js.map +1 -1
  22. package/esm/services/route-match-service.d.ts +12 -0
  23. package/esm/services/route-match-service.d.ts.map +1 -0
  24. package/esm/services/route-match-service.js +24 -0
  25. package/esm/services/route-match-service.js.map +1 -0
  26. package/esm/services/route-match-service.spec.d.ts +2 -0
  27. package/esm/services/route-match-service.spec.d.ts.map +1 -0
  28. package/esm/services/route-match-service.spec.js +120 -0
  29. package/esm/services/route-match-service.spec.js.map +1 -0
  30. package/esm/services/route-meta-utils.d.ts +57 -0
  31. package/esm/services/route-meta-utils.d.ts.map +1 -0
  32. package/esm/services/route-meta-utils.js +64 -0
  33. package/esm/services/route-meta-utils.js.map +1 -0
  34. package/esm/services/route-meta-utils.spec.d.ts +2 -0
  35. package/esm/services/route-meta-utils.spec.d.ts.map +1 -0
  36. package/esm/services/route-meta-utils.spec.js +217 -0
  37. package/esm/services/route-meta-utils.spec.js.map +1 -0
  38. package/esm/shade.d.ts.map +1 -1
  39. package/esm/shade.js +12 -1
  40. package/esm/shade.js.map +1 -1
  41. package/esm/shade.spec.js +93 -2
  42. package/esm/shade.spec.js.map +1 -1
  43. package/esm/view-transition.d.ts +38 -0
  44. package/esm/view-transition.d.ts.map +1 -0
  45. package/esm/view-transition.js +50 -0
  46. package/esm/view-transition.js.map +1 -0
  47. package/esm/view-transition.spec.d.ts +2 -0
  48. package/esm/view-transition.spec.d.ts.map +1 -0
  49. package/esm/view-transition.spec.js +184 -0
  50. package/esm/view-transition.spec.js.map +1 -0
  51. package/package.json +1 -1
  52. package/src/components/lazy-load.spec.tsx +78 -1
  53. package/src/components/lazy-load.tsx +10 -3
  54. package/src/components/nested-router.spec.tsx +389 -35
  55. package/src/components/nested-router.tsx +93 -10
  56. package/src/index.ts +1 -0
  57. package/src/services/index.ts +2 -0
  58. package/src/services/route-match-service.spec.ts +45 -0
  59. package/src/services/route-match-service.ts +17 -0
  60. package/src/services/route-meta-utils.spec.ts +243 -0
  61. package/src/services/route-meta-utils.ts +85 -0
  62. package/src/shade.spec.tsx +112 -2
  63. package/src/shade.ts +11 -1
  64. package/src/view-transition.spec.ts +218 -0
  65. package/src/view-transition.ts +66 -0
@@ -4,15 +4,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
4
  import { initializeShadeRoot } from '../initialize.js'
5
5
  import { createComponent } from '../shade-component.js'
6
6
  import { flushUpdates } from '../shade.js'
7
+ import { RouteMatchService } from '../services/route-match-service.js'
7
8
  import {
8
9
  buildMatchChain,
9
10
  findDivergenceIndex,
10
11
  NestedRouter,
11
12
  renderMatchChain,
13
+ resolveViewTransition,
12
14
  type MatchChainEntry,
13
15
  type NestedRoute,
14
16
  } from './nested-router.js'
15
- import { RouteLink } from './route-link.js'
17
+ import { NestedRouteLink } from './nested-route-link.js'
16
18
 
17
19
  describe('buildMatchChain', () => {
18
20
  it('should match a simple leaf route', () => {
@@ -320,18 +322,18 @@ describe('NestedRouter lifecycle hooks', () => {
320
322
  rootElement,
321
323
  jsxElement: (
322
324
  <div>
323
- <RouteLink id="child-a" href="/parent/child-a">
325
+ <NestedRouteLink id="child-a" href="/parent/child-a">
324
326
  child-a
325
- </RouteLink>
326
- <RouteLink id="child-b" href="/parent/child-b">
327
+ </NestedRouteLink>
328
+ <NestedRouteLink id="child-b" href="/parent/child-b">
327
329
  child-b
328
- </RouteLink>
329
- <RouteLink id="other" href="/other">
330
+ </NestedRouteLink>
331
+ <NestedRouteLink id="other" href="/other">
330
332
  other
331
- </RouteLink>
332
- <RouteLink id="nowhere" href="/nowhere">
333
+ </NestedRouteLink>
334
+ <NestedRouteLink id="nowhere" href="/nowhere">
333
335
  nowhere
334
- </RouteLink>
336
+ </NestedRouteLink>
335
337
  <NestedRouter
336
338
  routes={{
337
339
  '/parent': {
@@ -456,15 +458,15 @@ describe('NestedRouter latest-wins on rapid navigation', () => {
456
458
  rootElement,
457
459
  jsxElement: (
458
460
  <div>
459
- <RouteLink id="go-a" href="/route-a">
461
+ <NestedRouteLink id="go-a" href="/route-a">
460
462
  a
461
- </RouteLink>
462
- <RouteLink id="go-b" href="/route-b">
463
+ </NestedRouteLink>
464
+ <NestedRouteLink id="go-b" href="/route-b">
463
465
  b
464
- </RouteLink>
465
- <RouteLink id="go-c" href="/route-c">
466
+ </NestedRouteLink>
467
+ <NestedRouteLink id="go-c" href="/route-c">
466
468
  c
467
- </RouteLink>
469
+ </NestedRouteLink>
468
470
  <NestedRouter
469
471
  routes={{
470
472
  '/route-a': {
@@ -538,12 +540,12 @@ describe('NestedRouter lifecycle element scope', () => {
538
540
  rootElement,
539
541
  jsxElement: (
540
542
  <div>
541
- <RouteLink id="child-a" href="/parent/child-a">
543
+ <NestedRouteLink id="child-a" href="/parent/child-a">
542
544
  child-a
543
- </RouteLink>
544
- <RouteLink id="child-b" href="/parent/child-b">
545
+ </NestedRouteLink>
546
+ <NestedRouteLink id="child-b" href="/parent/child-b">
545
547
  child-b
546
- </RouteLink>
548
+ </NestedRouteLink>
547
549
  <NestedRouter
548
550
  routes={{
549
551
  '/parent': {
@@ -631,15 +633,15 @@ describe('NestedRouter flat routes', () => {
631
633
  rootElement,
632
634
  jsxElement: (
633
635
  <div>
634
- <RouteLink id="home" href="/">
636
+ <NestedRouteLink id="home" href="/">
635
637
  home
636
- </RouteLink>
637
- <RouteLink id="about" href="/about">
638
+ </NestedRouteLink>
639
+ <NestedRouteLink id="about" href="/about">
638
640
  about
639
- </RouteLink>
640
- <RouteLink id="contact" href="/contact">
641
+ </NestedRouteLink>
642
+ <NestedRouteLink id="contact" href="/contact">
641
643
  contact
642
- </RouteLink>
644
+ </NestedRouteLink>
643
645
  <NestedRouter
644
646
  routes={{
645
647
  '/about': { component: () => <div id="content">about-page</div> },
@@ -790,15 +792,15 @@ describe('NestedRouter route param changes', () => {
790
792
  rootElement,
791
793
  jsxElement: (
792
794
  <div>
793
- <RouteLink id="user-1" href="/users/1">
795
+ <NestedRouteLink id="user-1" href="/users/1">
794
796
  User 1
795
- </RouteLink>
796
- <RouteLink id="user-2" href="/users/2">
797
+ </NestedRouteLink>
798
+ <NestedRouteLink id="user-2" href="/users/2">
797
799
  User 2
798
- </RouteLink>
799
- <RouteLink id="user-3" href="/users/3">
800
+ </NestedRouteLink>
801
+ <NestedRouteLink id="user-3" href="/users/3">
800
802
  User 3
801
- </RouteLink>
803
+ </NestedRouteLink>
802
804
  <NestedRouter
803
805
  routes={{
804
806
  '/users/:id': {
@@ -866,12 +868,12 @@ describe('NestedRouter route param changes', () => {
866
868
  rootElement,
867
869
  jsxElement: (
868
870
  <div>
869
- <RouteLink id="alpha-dash" href="/org/alpha/dashboard">
871
+ <NestedRouteLink id="alpha-dash" href="/org/alpha/dashboard">
870
872
  Alpha Dashboard
871
- </RouteLink>
872
- <RouteLink id="beta-dash" href="/org/beta/dashboard">
873
+ </NestedRouteLink>
874
+ <NestedRouteLink id="beta-dash" href="/org/beta/dashboard">
873
875
  Beta Dashboard
874
- </RouteLink>
876
+ </NestedRouteLink>
875
877
  <NestedRouter
876
878
  routes={{
877
879
  '/org/:orgId': {
@@ -919,3 +921,355 @@ describe('NestedRouter route param changes', () => {
919
921
  })
920
922
  })
921
923
  })
924
+
925
+ describe('NestedRouter + RouteMatchService integration', () => {
926
+ beforeEach(() => {
927
+ document.body.innerHTML = '<div id="root"></div>'
928
+ })
929
+ afterEach(() => {
930
+ document.body.innerHTML = ''
931
+ })
932
+
933
+ it('should update RouteMatchService with the current match chain on navigation', async () => {
934
+ history.pushState(null, '', '/parent/child-a')
935
+
936
+ await usingAsync(new Injector(), async (injector) => {
937
+ const rootElement = document.getElementById('root') as HTMLDivElement
938
+ const routeMatchService = injector.getInstance(RouteMatchService)
939
+
940
+ const parentRoute: NestedRoute = {
941
+ meta: { title: 'Parent' },
942
+ component: ({ outlet }) => <div>{outlet ?? <div>parent-index</div>}</div>,
943
+ children: {
944
+ '/child-a': {
945
+ meta: { title: 'Child A' },
946
+ component: () => <div id="content">child-a</div>,
947
+ },
948
+ '/child-b': {
949
+ meta: { title: 'Child B' },
950
+ component: () => <div id="content">child-b</div>,
951
+ },
952
+ },
953
+ }
954
+
955
+ initializeShadeRoot({
956
+ injector,
957
+ rootElement,
958
+ jsxElement: (
959
+ <div>
960
+ <NestedRouteLink id="child-a" href="/parent/child-a">
961
+ child-a
962
+ </NestedRouteLink>
963
+ <NestedRouteLink id="child-b" href="/parent/child-b">
964
+ child-b
965
+ </NestedRouteLink>
966
+ <NestedRouteLink id="nowhere" href="/nowhere">
967
+ nowhere
968
+ </NestedRouteLink>
969
+ <NestedRouter routes={{ '/parent': parentRoute }} notFound={<div id="content">not found</div>} />
970
+ </div>
971
+ ),
972
+ })
973
+
974
+ // Initial load
975
+ await flushUpdates()
976
+ const initialChain = routeMatchService.currentMatchChain.getValue()
977
+ expect(initialChain).toHaveLength(2)
978
+ expect(initialChain[0].route.meta?.title).toBe('Parent')
979
+ expect(initialChain[1].route.meta?.title).toBe('Child A')
980
+
981
+ // Navigate to sibling child
982
+ document.getElementById('child-b')?.click()
983
+ await flushUpdates()
984
+ const updatedChain = routeMatchService.currentMatchChain.getValue()
985
+ expect(updatedChain).toHaveLength(2)
986
+ expect(updatedChain[0].route.meta?.title).toBe('Parent')
987
+ expect(updatedChain[1].route.meta?.title).toBe('Child B')
988
+
989
+ // Navigate to not-found
990
+ document.getElementById('nowhere')?.click()
991
+ await flushUpdates()
992
+ const notFoundChain = routeMatchService.currentMatchChain.getValue()
993
+ expect(notFoundChain).toEqual([])
994
+ })
995
+ })
996
+
997
+ it('should expose match params through RouteMatchService', async () => {
998
+ history.pushState(null, '', '/users/42')
999
+
1000
+ await usingAsync(new Injector(), async (injector) => {
1001
+ const rootElement = document.getElementById('root') as HTMLDivElement
1002
+ const routeMatchService = injector.getInstance(RouteMatchService)
1003
+
1004
+ initializeShadeRoot({
1005
+ injector,
1006
+ rootElement,
1007
+ jsxElement: (
1008
+ <NestedRouter
1009
+ routes={{
1010
+ '/users/:id': {
1011
+ meta: {
1012
+ title: ({ match }: { match: { params: Record<string, string> } }) => `User ${match.params.id}`,
1013
+ },
1014
+ component: ({ match }) => <div id="content">user-{(match.params as { id: string }).id}</div>,
1015
+ },
1016
+ }}
1017
+ />
1018
+ ),
1019
+ })
1020
+
1021
+ await flushUpdates()
1022
+ const chain = routeMatchService.currentMatchChain.getValue()
1023
+ expect(chain).toHaveLength(1)
1024
+ expect(chain[0].match.params).toEqual({ id: '42' })
1025
+ })
1026
+ })
1027
+ })
1028
+
1029
+ describe('resolveViewTransition', () => {
1030
+ const makeEntry = (viewTransition?: boolean | { types?: string[] }): MatchChainEntry => ({
1031
+ route: { component: () => <div />, viewTransition },
1032
+ match: { path: '/', params: {} },
1033
+ })
1034
+
1035
+ it('should return false when router config is undefined and route has no override', () => {
1036
+ expect(resolveViewTransition(undefined, [makeEntry()])).toBe(false)
1037
+ })
1038
+
1039
+ it('should return false when router config is false', () => {
1040
+ expect(resolveViewTransition(false, [makeEntry()])).toBe(false)
1041
+ })
1042
+
1043
+ it('should return config when router config is true', () => {
1044
+ expect(resolveViewTransition(true, [makeEntry()])).toEqual({ types: undefined })
1045
+ })
1046
+
1047
+ it('should return false when router is true but leaf route opts out', () => {
1048
+ expect(resolveViewTransition(true, [makeEntry(false)])).toBe(false)
1049
+ })
1050
+
1051
+ it('should use router-level types when route has no override', () => {
1052
+ expect(resolveViewTransition({ types: ['slide'] }, [makeEntry()])).toEqual({ types: ['slide'] })
1053
+ })
1054
+
1055
+ it('should prefer route-level types over router-level types', () => {
1056
+ expect(resolveViewTransition({ types: ['slide'] }, [makeEntry({ types: ['fade'] })])).toEqual({
1057
+ types: ['fade'],
1058
+ })
1059
+ })
1060
+
1061
+ it('should enable transitions when only the leaf route enables it', () => {
1062
+ expect(resolveViewTransition(undefined, [makeEntry(true)])).toEqual({ types: undefined })
1063
+ })
1064
+
1065
+ it('should use types from the innermost (leaf) route in a chain', () => {
1066
+ const parent = makeEntry({ types: ['parent-type'] })
1067
+ const child = makeEntry({ types: ['child-type'] })
1068
+ expect(resolveViewTransition(true, [parent, child])).toEqual({ types: ['child-type'] })
1069
+ })
1070
+ })
1071
+
1072
+ describe('NestedRouter view transitions', () => {
1073
+ let startViewTransitionSpy: ReturnType<typeof vi.fn>
1074
+
1075
+ beforeEach(() => {
1076
+ document.body.innerHTML = '<div id="root"></div>'
1077
+ startViewTransitionSpy = vi.fn((optionsOrCallback: StartViewTransitionOptions | (() => void)) => {
1078
+ const update = typeof optionsOrCallback === 'function' ? optionsOrCallback : optionsOrCallback.update
1079
+ update?.()
1080
+ return {
1081
+ finished: Promise.resolve(),
1082
+ ready: Promise.resolve(),
1083
+ updateCallbackDone: Promise.resolve(),
1084
+ skipTransition: vi.fn(),
1085
+ } as unknown as ViewTransition
1086
+ })
1087
+ document.startViewTransition = startViewTransitionSpy as typeof document.startViewTransition
1088
+ })
1089
+
1090
+ afterEach(() => {
1091
+ document.body.innerHTML = ''
1092
+ delete (document as unknown as Record<string, unknown>).startViewTransition
1093
+ })
1094
+
1095
+ it('should call startViewTransition when viewTransition is enabled', async () => {
1096
+ history.pushState(null, '', '/')
1097
+
1098
+ await usingAsync(new Injector(), async (injector) => {
1099
+ const rootElement = document.getElementById('root') as HTMLDivElement
1100
+
1101
+ initializeShadeRoot({
1102
+ injector,
1103
+ rootElement,
1104
+ jsxElement: (
1105
+ <div>
1106
+ <NestedRouteLink id="go-about" href="/about">
1107
+ about
1108
+ </NestedRouteLink>
1109
+ <NestedRouter
1110
+ viewTransition
1111
+ routes={{
1112
+ '/about': { component: () => <div id="content">about</div> },
1113
+ '/': { component: () => <div id="content">home</div> },
1114
+ }}
1115
+ />
1116
+ </div>
1117
+ ),
1118
+ })
1119
+
1120
+ await flushUpdates()
1121
+ startViewTransitionSpy.mockClear()
1122
+
1123
+ document.getElementById('go-about')?.click()
1124
+ await flushUpdates()
1125
+
1126
+ expect(startViewTransitionSpy).toHaveBeenCalledTimes(1)
1127
+ expect(document.getElementById('content')?.innerHTML).toBe('about')
1128
+ })
1129
+ })
1130
+
1131
+ it('should not call startViewTransition when viewTransition is not set', async () => {
1132
+ history.pushState(null, '', '/')
1133
+
1134
+ await usingAsync(new Injector(), async (injector) => {
1135
+ const rootElement = document.getElementById('root') as HTMLDivElement
1136
+
1137
+ initializeShadeRoot({
1138
+ injector,
1139
+ rootElement,
1140
+ jsxElement: (
1141
+ <div>
1142
+ <NestedRouteLink id="go-about" href="/about">
1143
+ about
1144
+ </NestedRouteLink>
1145
+ <NestedRouter
1146
+ routes={{
1147
+ '/about': { component: () => <div id="content">about</div> },
1148
+ '/': { component: () => <div id="content">home</div> },
1149
+ }}
1150
+ />
1151
+ </div>
1152
+ ),
1153
+ })
1154
+
1155
+ await flushUpdates()
1156
+ startViewTransitionSpy.mockClear()
1157
+
1158
+ document.getElementById('go-about')?.click()
1159
+ await flushUpdates()
1160
+
1161
+ expect(startViewTransitionSpy).not.toHaveBeenCalled()
1162
+ expect(document.getElementById('content')?.innerHTML).toBe('about')
1163
+ })
1164
+ })
1165
+
1166
+ it('should pass types to startViewTransition when configured', async () => {
1167
+ history.pushState(null, '', '/')
1168
+
1169
+ await usingAsync(new Injector(), async (injector) => {
1170
+ const rootElement = document.getElementById('root') as HTMLDivElement
1171
+
1172
+ initializeShadeRoot({
1173
+ injector,
1174
+ rootElement,
1175
+ jsxElement: (
1176
+ <div>
1177
+ <NestedRouteLink id="go-about" href="/about">
1178
+ about
1179
+ </NestedRouteLink>
1180
+ <NestedRouter
1181
+ viewTransition={{ types: ['slide'] }}
1182
+ routes={{
1183
+ '/about': { component: () => <div id="content">about</div> },
1184
+ '/': { component: () => <div id="content">home</div> },
1185
+ }}
1186
+ />
1187
+ </div>
1188
+ ),
1189
+ })
1190
+
1191
+ await flushUpdates()
1192
+ startViewTransitionSpy.mockClear()
1193
+
1194
+ document.getElementById('go-about')?.click()
1195
+ await flushUpdates()
1196
+
1197
+ expect(startViewTransitionSpy).toHaveBeenCalledWith(expect.objectContaining({ types: ['slide'] }))
1198
+ })
1199
+ })
1200
+
1201
+ it('should respect per-route viewTransition: false override', async () => {
1202
+ history.pushState(null, '', '/')
1203
+
1204
+ await usingAsync(new Injector(), async (injector) => {
1205
+ const rootElement = document.getElementById('root') as HTMLDivElement
1206
+
1207
+ initializeShadeRoot({
1208
+ injector,
1209
+ rootElement,
1210
+ jsxElement: (
1211
+ <div>
1212
+ <NestedRouteLink id="go-about" href="/about">
1213
+ about
1214
+ </NestedRouteLink>
1215
+ <NestedRouter
1216
+ viewTransition
1217
+ routes={{
1218
+ '/about': {
1219
+ component: () => <div id="content">about</div>,
1220
+ viewTransition: false,
1221
+ },
1222
+ '/': { component: () => <div id="content">home</div> },
1223
+ }}
1224
+ />
1225
+ </div>
1226
+ ),
1227
+ })
1228
+
1229
+ await flushUpdates()
1230
+ startViewTransitionSpy.mockClear()
1231
+
1232
+ document.getElementById('go-about')?.click()
1233
+ await flushUpdates()
1234
+
1235
+ expect(startViewTransitionSpy).not.toHaveBeenCalled()
1236
+ expect(document.getElementById('content')?.innerHTML).toBe('about')
1237
+ })
1238
+ })
1239
+
1240
+ it('should fall back gracefully when startViewTransition is not available', async () => {
1241
+ delete (document as unknown as Record<string, unknown>).startViewTransition
1242
+
1243
+ history.pushState(null, '', '/')
1244
+
1245
+ await usingAsync(new Injector(), async (injector) => {
1246
+ const rootElement = document.getElementById('root') as HTMLDivElement
1247
+
1248
+ initializeShadeRoot({
1249
+ injector,
1250
+ rootElement,
1251
+ jsxElement: (
1252
+ <div>
1253
+ <NestedRouteLink id="go-about" href="/about">
1254
+ about
1255
+ </NestedRouteLink>
1256
+ <NestedRouter
1257
+ viewTransition
1258
+ routes={{
1259
+ '/about': { component: () => <div id="content">about</div> },
1260
+ '/': { component: () => <div id="content">home</div> },
1261
+ }}
1262
+ />
1263
+ </div>
1264
+ ),
1265
+ })
1266
+
1267
+ await flushUpdates()
1268
+
1269
+ document.getElementById('go-about')?.click()
1270
+ await flushUpdates()
1271
+
1272
+ expect(document.getElementById('content')?.innerHTML).toBe('about')
1273
+ })
1274
+ })
1275
+ })
@@ -1,10 +1,47 @@
1
+ import type { Injector } from '@furystack/inject'
1
2
  import { ObservableAlreadyDisposedError } from '@furystack/utils'
2
3
  import type { MatchOptions, MatchResult } from 'path-to-regexp'
3
4
  import { match } from 'path-to-regexp'
4
5
  import type { RenderOptions } from '../models/render-options.js'
5
6
  import { LocationService } from '../services/location-service.js'
7
+ import { RouteMatchService } from '../services/route-match-service.js'
6
8
  import { createComponent, setRenderMode } from '../shade-component.js'
7
9
  import { Shade } from '../shade.js'
10
+ import type { ViewTransitionConfig } from '../view-transition.js'
11
+ import { maybeViewTransition } from '../view-transition.js'
12
+
13
+ /**
14
+ * Options passed to a dynamic title resolver function.
15
+ * @typeParam TMatchResult - The type of matched URL parameters
16
+ */
17
+ export type TitleResolverOptions<TMatchResult = unknown> = {
18
+ match: MatchResult<TMatchResult extends object ? TMatchResult : object>
19
+ injector: Injector
20
+ }
21
+
22
+ /**
23
+ * Metadata associated with a route entry.
24
+ * Used by consumers (breadcrumbs, document title, navigation trees) to
25
+ * derive display information from the route hierarchy.
26
+ *
27
+ * This is an `interface` so that applications can augment it with custom fields
28
+ * via declaration merging:
29
+ *
30
+ * @example
31
+ * ```typescript
32
+ * declare module '@furystack/shades' {
33
+ * interface NestedRouteMeta {
34
+ * icon?: IconDefinition
35
+ * hidden?: boolean
36
+ * }
37
+ * }
38
+ * ```
39
+ *
40
+ * @typeParam TMatchResult - The type of matched URL parameters
41
+ */
42
+ export interface NestedRouteMeta<TMatchResult = unknown> {
43
+ title?: string | ((options: TitleResolverOptions<TMatchResult>) => string | Promise<string>)
44
+ }
8
45
 
9
46
  /**
10
47
  * A single route entry in a NestedRouter configuration.
@@ -13,15 +50,28 @@ import { Shade } from '../shade.js'
13
50
  * @typeParam TMatchResult - The type of matched URL parameters
14
51
  */
15
52
  export type NestedRoute<TMatchResult = unknown> = {
53
+ meta?: NestedRouteMeta<TMatchResult>
16
54
  component: (options: {
17
55
  currentUrl: string
18
56
  match: MatchResult<TMatchResult extends object ? TMatchResult : object>
19
57
  outlet?: JSX.Element
20
58
  }) => JSX.Element
21
59
  routingOptions?: MatchOptions
60
+ /**
61
+ * Called after the route's DOM has been mounted. When view transitions are enabled,
62
+ * this runs after the transition's update callback has completed and the new DOM is in place.
63
+ * Use for imperative side effects like data fetching or focus management — not for visual
64
+ * animations, which are handled by the View Transition API when `viewTransition` is enabled.
65
+ */
22
66
  onVisit?: (options: RenderOptions<unknown> & { element: JSX.Element }) => Promise<void>
67
+ /**
68
+ * Called before the route's DOM is removed (and before the view transition starts, if enabled).
69
+ * Use for cleanup or teardown logic — not for exit animations, which are handled by the
70
+ * View Transition API when `viewTransition` is enabled.
71
+ */
23
72
  onLeave?: (options: RenderOptions<unknown> & { element: JSX.Element }) => Promise<void>
24
73
  children?: Record<string, NestedRoute<any>>
74
+ viewTransition?: boolean | ViewTransitionConfig
25
75
  }
26
76
 
27
77
  /**
@@ -31,6 +81,7 @@ export type NestedRoute<TMatchResult = unknown> = {
31
81
  export type NestedRouterProps = {
32
82
  routes: Record<string, NestedRoute<any>>
33
83
  notFound?: JSX.Element
84
+ viewTransition?: boolean | ViewTransitionConfig
34
85
  }
35
86
 
36
87
  /**
@@ -164,6 +215,29 @@ export const renderMatchChain = (chain: MatchChainEntry[], currentUrl: string):
164
215
  return { jsx: outlet as JSX.Element, chainElements }
165
216
  }
166
217
 
218
+ /**
219
+ * Resolves the effective view transition config for a navigation by merging
220
+ * the router-level default with the innermost (leaf) route's override.
221
+ * A per-route `false` disables transitions even when the router default is on.
222
+ */
223
+ export const resolveViewTransition = (
224
+ routerConfig: boolean | ViewTransitionConfig | undefined,
225
+ newChain: MatchChainEntry[],
226
+ ): ViewTransitionConfig | false => {
227
+ if (!routerConfig && routerConfig !== undefined) return false
228
+
229
+ const leafRoute = newChain[newChain.length - 1]?.route
230
+ const routeConfig = leafRoute?.viewTransition
231
+
232
+ if (routeConfig === false) return false
233
+ if (!routerConfig && !routeConfig) return false
234
+
235
+ const baseTypes = typeof routerConfig === 'object' ? routerConfig.types : undefined
236
+ const routeTypes = typeof routeConfig === 'object' ? routeConfig.types : undefined
237
+
238
+ return { types: routeTypes ?? baseTypes }
239
+ }
240
+
167
241
  /**
168
242
  * A nested router component that supports hierarchical route definitions
169
243
  * with parent/child relationships. Parent routes receive an `outlet` prop
@@ -201,7 +275,6 @@ export const NestedRouter = Shade<NestedRouterProps>({
201
275
  if (hasChanged) {
202
276
  const version = ++versionRef.current
203
277
 
204
- // Call onLeave for routes that are being left (from divergence point to end of old chain)
205
278
  for (let i = lastChainEntries.length - 1; i >= divergeIndex; i--) {
206
279
  await lastChainEntries[i].route.onLeave?.({ ...options, element: lastChainElements[i] })
207
280
  if (version !== versionRef.current) return
@@ -215,9 +288,15 @@ export const NestedRouter = Shade<NestedRouterProps>({
215
288
  setRenderMode(false)
216
289
  }
217
290
  if (version !== versionRef.current) return
218
- setState({ matchChain: newChain, jsx: newResult.jsx, chainElements: newResult.chainElements })
219
291
 
220
- // Call onVisit for routes that are being entered (from divergence point to end of new chain)
292
+ const applyUpdate = () => {
293
+ setState({ matchChain: newChain, jsx: newResult.jsx, chainElements: newResult.chainElements })
294
+ injector.getInstance(RouteMatchService).currentMatchChain.setValue(newChain)
295
+ }
296
+
297
+ const vtConfig = resolveViewTransition(options.props.viewTransition, newChain)
298
+ await maybeViewTransition(vtConfig === false ? undefined : vtConfig, applyUpdate)
299
+
221
300
  for (let i = divergeIndex; i < newChain.length; i++) {
222
301
  await newChain[i].route.onVisit?.({ ...options, element: newResult.chainElements[i] })
223
302
  if (version !== versionRef.current) return
@@ -226,17 +305,21 @@ export const NestedRouter = Shade<NestedRouterProps>({
226
305
  } else if (lastChain !== null) {
227
306
  const version = ++versionRef.current
228
307
 
229
- // No match found — call onLeave for all active routes and show notFound.
230
- // The null sentinel prevents re-entering this block on re-render.
231
308
  for (let i = (lastChain?.length ?? 0) - 1; i >= 0; i--) {
232
309
  await lastChain[i].route.onLeave?.({ ...options, element: lastChainElements[i] })
233
310
  if (version !== versionRef.current) return
234
311
  }
235
- setState({
236
- matchChain: null,
237
- jsx: options.props.notFound || <div />,
238
- chainElements: [],
239
- })
312
+
313
+ const applyNotFound = () => {
314
+ setState({
315
+ matchChain: null,
316
+ jsx: options.props.notFound || <div />,
317
+ chainElements: [],
318
+ })
319
+ injector.getInstance(RouteMatchService).currentMatchChain.setValue([])
320
+ }
321
+
322
+ await maybeViewTransition(options.props.viewTransition, applyNotFound)
240
323
  }
241
324
  } catch (e) {
242
325
  if (!(e instanceof ObservableAlreadyDisposedError)) {
package/src/index.ts CHANGED
@@ -9,4 +9,5 @@ export * from './shade.js'
9
9
  export * from './style-manager.js'
10
10
  export * from './styled-element.js'
11
11
  export * from './styled-shade.js'
12
+ export * from './view-transition.js'
12
13
  import './jsx.js'
@@ -1,2 +1,4 @@
1
1
  export * from './location-service.js'
2
+ export * from './route-match-service.js'
3
+ export * from './route-meta-utils.js'
2
4
  export * from './screen-service.js'