@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.
- package/CHANGELOG.md +99 -0
- package/esm/components/lazy-load.d.ts +2 -0
- package/esm/components/lazy-load.d.ts.map +1 -1
- package/esm/components/lazy-load.js +8 -3
- package/esm/components/lazy-load.js.map +1 -1
- package/esm/components/lazy-load.spec.js +59 -1
- package/esm/components/lazy-load.spec.js.map +1 -1
- package/esm/components/nested-router.d.ts +53 -0
- package/esm/components/nested-router.d.ts.map +1 -1
- package/esm/components/nested-router.js +35 -10
- package/esm/components/nested-router.js.map +1 -1
- package/esm/components/nested-router.spec.js +267 -19
- package/esm/components/nested-router.spec.js.map +1 -1
- package/esm/index.d.ts +1 -0
- package/esm/index.d.ts.map +1 -1
- package/esm/index.js +1 -0
- package/esm/index.js.map +1 -1
- package/esm/services/index.d.ts +2 -0
- package/esm/services/index.d.ts.map +1 -1
- package/esm/services/index.js +2 -0
- package/esm/services/index.js.map +1 -1
- package/esm/services/route-match-service.d.ts +12 -0
- package/esm/services/route-match-service.d.ts.map +1 -0
- package/esm/services/route-match-service.js +24 -0
- package/esm/services/route-match-service.js.map +1 -0
- package/esm/services/route-match-service.spec.d.ts +2 -0
- package/esm/services/route-match-service.spec.d.ts.map +1 -0
- package/esm/services/route-match-service.spec.js +120 -0
- package/esm/services/route-match-service.spec.js.map +1 -0
- package/esm/services/route-meta-utils.d.ts +57 -0
- package/esm/services/route-meta-utils.d.ts.map +1 -0
- package/esm/services/route-meta-utils.js +64 -0
- package/esm/services/route-meta-utils.js.map +1 -0
- package/esm/services/route-meta-utils.spec.d.ts +2 -0
- package/esm/services/route-meta-utils.spec.d.ts.map +1 -0
- package/esm/services/route-meta-utils.spec.js +217 -0
- package/esm/services/route-meta-utils.spec.js.map +1 -0
- package/esm/shade.d.ts.map +1 -1
- package/esm/shade.js +12 -1
- package/esm/shade.js.map +1 -1
- package/esm/shade.spec.js +93 -2
- package/esm/shade.spec.js.map +1 -1
- package/esm/view-transition.d.ts +38 -0
- package/esm/view-transition.d.ts.map +1 -0
- package/esm/view-transition.js +50 -0
- package/esm/view-transition.js.map +1 -0
- package/esm/view-transition.spec.d.ts +2 -0
- package/esm/view-transition.spec.d.ts.map +1 -0
- package/esm/view-transition.spec.js +184 -0
- package/esm/view-transition.spec.js.map +1 -0
- package/package.json +1 -1
- package/src/components/lazy-load.spec.tsx +78 -1
- package/src/components/lazy-load.tsx +10 -3
- package/src/components/nested-router.spec.tsx +389 -35
- package/src/components/nested-router.tsx +93 -10
- package/src/index.ts +1 -0
- package/src/services/index.ts +2 -0
- package/src/services/route-match-service.spec.ts +45 -0
- package/src/services/route-match-service.ts +17 -0
- package/src/services/route-meta-utils.spec.ts +243 -0
- package/src/services/route-meta-utils.ts +85 -0
- package/src/shade.spec.tsx +112 -2
- package/src/shade.ts +11 -1
- package/src/view-transition.spec.ts +218 -0
- 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 {
|
|
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
|
-
<
|
|
325
|
+
<NestedRouteLink id="child-a" href="/parent/child-a">
|
|
324
326
|
child-a
|
|
325
|
-
</
|
|
326
|
-
<
|
|
327
|
+
</NestedRouteLink>
|
|
328
|
+
<NestedRouteLink id="child-b" href="/parent/child-b">
|
|
327
329
|
child-b
|
|
328
|
-
</
|
|
329
|
-
<
|
|
330
|
+
</NestedRouteLink>
|
|
331
|
+
<NestedRouteLink id="other" href="/other">
|
|
330
332
|
other
|
|
331
|
-
</
|
|
332
|
-
<
|
|
333
|
+
</NestedRouteLink>
|
|
334
|
+
<NestedRouteLink id="nowhere" href="/nowhere">
|
|
333
335
|
nowhere
|
|
334
|
-
</
|
|
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
|
-
<
|
|
461
|
+
<NestedRouteLink id="go-a" href="/route-a">
|
|
460
462
|
a
|
|
461
|
-
</
|
|
462
|
-
<
|
|
463
|
+
</NestedRouteLink>
|
|
464
|
+
<NestedRouteLink id="go-b" href="/route-b">
|
|
463
465
|
b
|
|
464
|
-
</
|
|
465
|
-
<
|
|
466
|
+
</NestedRouteLink>
|
|
467
|
+
<NestedRouteLink id="go-c" href="/route-c">
|
|
466
468
|
c
|
|
467
|
-
</
|
|
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
|
-
<
|
|
543
|
+
<NestedRouteLink id="child-a" href="/parent/child-a">
|
|
542
544
|
child-a
|
|
543
|
-
</
|
|
544
|
-
<
|
|
545
|
+
</NestedRouteLink>
|
|
546
|
+
<NestedRouteLink id="child-b" href="/parent/child-b">
|
|
545
547
|
child-b
|
|
546
|
-
</
|
|
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
|
-
<
|
|
636
|
+
<NestedRouteLink id="home" href="/">
|
|
635
637
|
home
|
|
636
|
-
</
|
|
637
|
-
<
|
|
638
|
+
</NestedRouteLink>
|
|
639
|
+
<NestedRouteLink id="about" href="/about">
|
|
638
640
|
about
|
|
639
|
-
</
|
|
640
|
-
<
|
|
641
|
+
</NestedRouteLink>
|
|
642
|
+
<NestedRouteLink id="contact" href="/contact">
|
|
641
643
|
contact
|
|
642
|
-
</
|
|
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
|
-
<
|
|
795
|
+
<NestedRouteLink id="user-1" href="/users/1">
|
|
794
796
|
User 1
|
|
795
|
-
</
|
|
796
|
-
<
|
|
797
|
+
</NestedRouteLink>
|
|
798
|
+
<NestedRouteLink id="user-2" href="/users/2">
|
|
797
799
|
User 2
|
|
798
|
-
</
|
|
799
|
-
<
|
|
800
|
+
</NestedRouteLink>
|
|
801
|
+
<NestedRouteLink id="user-3" href="/users/3">
|
|
800
802
|
User 3
|
|
801
|
-
</
|
|
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
|
-
<
|
|
871
|
+
<NestedRouteLink id="alpha-dash" href="/org/alpha/dashboard">
|
|
870
872
|
Alpha Dashboard
|
|
871
|
-
</
|
|
872
|
-
<
|
|
873
|
+
</NestedRouteLink>
|
|
874
|
+
<NestedRouteLink id="beta-dash" href="/org/beta/dashboard">
|
|
873
875
|
Beta Dashboard
|
|
874
|
-
</
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
package/src/services/index.ts
CHANGED