@ankhorage/contracts 10.0.0 → 11.0.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 +15 -0
- package/README.md +1 -1
- package/dist/appManifest/icon.d.ts +3 -0
- package/dist/appManifest/icon.d.ts.map +1 -0
- package/dist/appManifest/icon.js +20 -0
- package/dist/appManifest/icon.js.map +1 -0
- package/dist/appManifest/infra.d.ts.map +1 -1
- package/dist/appManifest/infra.js +1 -9
- package/dist/appManifest/infra.js.map +1 -1
- package/dist/appManifest/navigator.d.ts +3 -0
- package/dist/appManifest/navigator.d.ts.map +1 -0
- package/dist/appManifest/navigator.js +142 -0
- package/dist/appManifest/navigator.js.map +1 -0
- package/dist/appManifest/navigatorOptions.d.ts +13 -0
- package/dist/appManifest/navigatorOptions.d.ts.map +1 -0
- package/dist/appManifest/navigatorOptions.js +226 -0
- package/dist/appManifest/navigatorOptions.js.map +1 -0
- package/dist/appManifest/screens.d.ts +1 -2
- package/dist/appManifest/screens.d.ts.map +1 -1
- package/dist/appManifest/screens.js +2 -124
- package/dist/appManifest/screens.js.map +1 -1
- package/dist/navigator.d.ts +101 -14
- package/dist/navigator.d.ts.map +1 -1
- package/dist/navigator.js +23 -1
- package/dist/navigator.js.map +1 -1
- package/dist/types.d.ts +14 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/appManifest/icon.ts +26 -0
- package/src/appManifest/infra.ts +1 -12
- package/src/appManifest/navigator.ts +179 -0
- package/src/appManifest/navigatorOptions.ts +292 -0
- package/src/appManifest/screens.ts +3 -181
- package/src/contracts.test.ts +1 -1
- package/src/navigator-validation.test.ts +153 -0
- package/src/navigator.test.ts +176 -10
- package/src/navigator.ts +149 -13
- package/src/types.ts +16 -4
|
@@ -1,29 +1,13 @@
|
|
|
1
1
|
import { COLOR_HARMONIES } from '@ankhorage/color-theory';
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
CUSTOM_TABS_PRESENTATIONS,
|
|
5
|
-
FIXED_CUSTOM_TABS_PRESENTATIONS,
|
|
6
|
-
JAVASCRIPT_TABS_PRESENTATIONS,
|
|
7
|
-
NAVIGATOR_PRESETS,
|
|
8
|
-
NAVIGATOR_TYPES,
|
|
9
|
-
} from '../navigator';
|
|
10
3
|
import { APP_CATEGORIES } from '../types';
|
|
11
4
|
import { isBindingValueSource, isScreenDataLoaderDefinition } from './bindings';
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
isOptionalString,
|
|
16
|
-
isRecord,
|
|
17
|
-
isStringArray,
|
|
18
|
-
} from './shared';
|
|
5
|
+
import { isOptionalNumber, isOptionalString, isRecord } from './shared';
|
|
6
|
+
|
|
7
|
+
export { isAppNavigatorManifest } from './navigator';
|
|
19
8
|
|
|
20
9
|
const APP_CATEGORY_SET = new Set<string>(APP_CATEGORIES);
|
|
21
10
|
const COLOR_HARMONY_SET = new Set<string>(COLOR_HARMONIES);
|
|
22
|
-
const CUSTOM_TABS_PRESENTATION_SET = new Set<string>(CUSTOM_TABS_PRESENTATIONS);
|
|
23
|
-
const FIXED_CUSTOM_TABS_PRESENTATION_SET = new Set<string>(FIXED_CUSTOM_TABS_PRESENTATIONS);
|
|
24
|
-
const JAVASCRIPT_TABS_PRESENTATION_SET = new Set<string>(JAVASCRIPT_TABS_PRESENTATIONS);
|
|
25
|
-
const NAVIGATOR_PRESET_SET = new Set<string>(NAVIGATOR_PRESETS);
|
|
26
|
-
const NAVIGATOR_TYPE_SET = new Set<string>(NAVIGATOR_TYPES);
|
|
27
11
|
const SPLASH_SCREEN_RESIZE_MODE_SET = new Set<string>(['contain', 'cover', 'native']);
|
|
28
12
|
|
|
29
13
|
export function isManifestMetadata(value: unknown): boolean {
|
|
@@ -50,19 +34,6 @@ export function isThemeConfig(value: unknown): boolean {
|
|
|
50
34
|
);
|
|
51
35
|
}
|
|
52
36
|
|
|
53
|
-
/*** Validate the complete serialized `AppManifest.navigator` slice. */
|
|
54
|
-
export function isAppNavigatorManifest(value: unknown): boolean {
|
|
55
|
-
return (
|
|
56
|
-
isRecord(value) &&
|
|
57
|
-
isNavigatorNode(value) &&
|
|
58
|
-
(value.preset === undefined ||
|
|
59
|
-
(typeof value.preset === 'string' && NAVIGATOR_PRESET_SET.has(value.preset))) &&
|
|
60
|
-
(value.flows === undefined || isNavigatorFlows(value.flows)) &&
|
|
61
|
-
(value.defaults === undefined || isNavigatorDefaults(value.defaults)) &&
|
|
62
|
-
(value.platforms === undefined || isNavigatorPlatforms(value.platforms))
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
37
|
export function isScreenRegistry(value: unknown): boolean {
|
|
67
38
|
return (
|
|
68
39
|
isRecord(value) &&
|
|
@@ -129,155 +100,6 @@ function isScreenSpec(value: unknown): boolean {
|
|
|
129
100
|
);
|
|
130
101
|
}
|
|
131
102
|
|
|
132
|
-
/*** Validate one nested navigator node independently from app-level authoring metadata. */
|
|
133
|
-
function isNavigatorNode(value: unknown): boolean {
|
|
134
|
-
return (
|
|
135
|
-
isRecord(value) &&
|
|
136
|
-
typeof value.type === 'string' &&
|
|
137
|
-
NAVIGATOR_TYPE_SET.has(value.type) &&
|
|
138
|
-
isOptionalString(value.initialRouteName) &&
|
|
139
|
-
Array.isArray(value.routes) &&
|
|
140
|
-
value.routes.every(isRouteDefinition) &&
|
|
141
|
-
(value.options === undefined || isRecord(value.options)) &&
|
|
142
|
-
(value.type !== 'tabs' || isTabsImplementationConfig(value))
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function isRouteDefinition(value: unknown): boolean {
|
|
147
|
-
return (
|
|
148
|
-
isRecord(value) &&
|
|
149
|
-
typeof value.name === 'string' &&
|
|
150
|
-
isOptionalString(value.path) &&
|
|
151
|
-
isOptionalString(value.label) &&
|
|
152
|
-
(value.icon === undefined || isIconSpec(value.icon)) &&
|
|
153
|
-
isOptionalBoolean(value.showInPrimaryNavigation) &&
|
|
154
|
-
(value.guards === undefined || isStringArray(value.guards)) &&
|
|
155
|
-
isOptionalString(value.screenId) &&
|
|
156
|
-
(value.navigator === undefined || isNavigatorNode(value.navigator))
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function isIconSpec(value: unknown): boolean {
|
|
161
|
-
return (
|
|
162
|
-
isRecord(value) &&
|
|
163
|
-
typeof value.name === 'string' &&
|
|
164
|
-
isOptionalString(value.provider) &&
|
|
165
|
-
(value.size === undefined ||
|
|
166
|
-
typeof value.size === 'string' ||
|
|
167
|
-
typeof value.size === 'number') &&
|
|
168
|
-
isOptionalString(value.color)
|
|
169
|
-
);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/*** Validate tabs implementation desired state, with omitted implementation meaning adaptive. */
|
|
173
|
-
function isTabsImplementationConfig(value: Record<string, unknown>): boolean {
|
|
174
|
-
if (value.implementation === undefined || value.implementation === 'adaptive') {
|
|
175
|
-
return (
|
|
176
|
-
(value.native === undefined || isNativeTabsConfig(value.native)) &&
|
|
177
|
-
(value.web === undefined || isCustomTabsWebConfig(value.web))
|
|
178
|
-
);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
if (value.implementation === 'native') return true;
|
|
182
|
-
|
|
183
|
-
if (value.implementation === 'javascript') {
|
|
184
|
-
return (
|
|
185
|
-
value.presentation === undefined ||
|
|
186
|
-
(typeof value.presentation === 'string' &&
|
|
187
|
-
JAVASCRIPT_TABS_PRESENTATION_SET.has(value.presentation))
|
|
188
|
-
);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
return value.implementation === 'custom' && isCustomTabsConfig(value);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/*** Validate the native branch used by adaptive tabs. */
|
|
195
|
-
function isNativeTabsConfig(value: unknown): boolean {
|
|
196
|
-
return isRecord(value) && value.implementation === 'native';
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/*** Validate a full custom-tabs implementation config. */
|
|
200
|
-
function isCustomTabsConfig(value: Record<string, unknown>): boolean {
|
|
201
|
-
return value.implementation === 'custom' && isCustomTabsPresentationConfig(value);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/*** Validate the implementation-free custom-tabs Web branch inside adaptive tabs. */
|
|
205
|
-
function isCustomTabsWebConfig(value: unknown): boolean {
|
|
206
|
-
return (
|
|
207
|
-
isRecord(value) && value.implementation === undefined && isCustomTabsPresentationConfig(value)
|
|
208
|
-
);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/*** Validate custom-tab presentation and its conditional serializable configuration. */
|
|
212
|
-
function isCustomTabsPresentationConfig(value: Record<string, unknown>): boolean {
|
|
213
|
-
if (
|
|
214
|
-
typeof value.presentation !== 'string' ||
|
|
215
|
-
!CUSTOM_TABS_PRESENTATION_SET.has(value.presentation)
|
|
216
|
-
) {
|
|
217
|
-
return false;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
if (value.responsive !== undefined && !isResponsiveTabsPresentation(value.responsive)) {
|
|
221
|
-
return false;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
if (!isOptionalString(value.customPresentationId)) return false;
|
|
225
|
-
if (value.presentation === 'responsive' && value.responsive === undefined) return false;
|
|
226
|
-
|
|
227
|
-
return (
|
|
228
|
-
value.presentation !== 'custom' ||
|
|
229
|
-
(typeof value.customPresentationId === 'string' && value.customPresentationId.length > 0)
|
|
230
|
-
);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/*** Validate semantic compact/medium/expanded custom-tab presentation mapping. */
|
|
234
|
-
function isResponsiveTabsPresentation(value: unknown): boolean {
|
|
235
|
-
return (
|
|
236
|
-
isRecord(value) &&
|
|
237
|
-
typeof value.compact === 'string' &&
|
|
238
|
-
FIXED_CUSTOM_TABS_PRESENTATION_SET.has(value.compact) &&
|
|
239
|
-
(value.medium === undefined ||
|
|
240
|
-
(typeof value.medium === 'string' && FIXED_CUSTOM_TABS_PRESENTATION_SET.has(value.medium))) &&
|
|
241
|
-
typeof value.expanded === 'string' &&
|
|
242
|
-
FIXED_CUSTOM_TABS_PRESENTATION_SET.has(value.expanded)
|
|
243
|
-
);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/*** Validate optional app-level navigator flow intent. */
|
|
247
|
-
function isNavigatorFlows(value: unknown): boolean {
|
|
248
|
-
return (
|
|
249
|
-
isRecord(value) &&
|
|
250
|
-
isOptionalBoolean(value.onboarding) &&
|
|
251
|
-
isOptionalBoolean(value.authentication)
|
|
252
|
-
);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/*** Validate package-owned navigator defaults without requiring a navigator node type. */
|
|
256
|
-
function isNavigatorDefaults(value: unknown): boolean {
|
|
257
|
-
return (
|
|
258
|
-
isRecord(value) &&
|
|
259
|
-
(value.tabs === undefined || (isRecord(value.tabs) && isTabsImplementationConfig(value.tabs)))
|
|
260
|
-
);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/*** Validate per-platform navigator implementation overrides. */
|
|
264
|
-
function isNavigatorPlatforms(value: unknown): boolean {
|
|
265
|
-
return (
|
|
266
|
-
isRecord(value) &&
|
|
267
|
-
(value.android === undefined || isNavigatorPlatformConfig(value.android)) &&
|
|
268
|
-
(value.ios === undefined || isNavigatorPlatformConfig(value.ios)) &&
|
|
269
|
-
(value.web === undefined || isNavigatorPlatformConfig(value.web))
|
|
270
|
-
);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/*** Validate one platform-specific navigator override slice. */
|
|
274
|
-
function isNavigatorPlatformConfig(value: unknown): boolean {
|
|
275
|
-
return (
|
|
276
|
-
isRecord(value) &&
|
|
277
|
-
(value.tabs === undefined || (isRecord(value.tabs) && isTabsImplementationConfig(value.tabs)))
|
|
278
|
-
);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
103
|
function isSplashScreenModeSpec(value: unknown): boolean {
|
|
282
104
|
return (
|
|
283
105
|
isRecord(value) &&
|
package/src/contracts.test.ts
CHANGED
|
@@ -80,7 +80,7 @@ describe('contracts', () => {
|
|
|
80
80
|
});
|
|
81
81
|
|
|
82
82
|
it('exports stable platform constants', () => {
|
|
83
|
-
expect(NAVIGATOR_TYPES).toEqual(['stack', 'tabs', 'drawer']);
|
|
83
|
+
expect(NAVIGATOR_TYPES).toEqual(['slot', 'stack', 'tabs', 'drawer', 'split-view', 'custom']);
|
|
84
84
|
expect(APP_CATEGORIES).toEqual([
|
|
85
85
|
'books_reading',
|
|
86
86
|
'business_productivity',
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from 'bun:test';
|
|
5
|
+
|
|
6
|
+
import { isAppNavigatorManifest } from './appManifest/screens';
|
|
7
|
+
import type {
|
|
8
|
+
CustomNavigatorNode,
|
|
9
|
+
CustomTabsConfig,
|
|
10
|
+
NavigatorNode,
|
|
11
|
+
StackImplementationConfig,
|
|
12
|
+
} from './navigator';
|
|
13
|
+
|
|
14
|
+
describe('app navigator manifest type discrimination', () => {
|
|
15
|
+
it('prevents incompatible options and non-serializable configuration at compile time', () => {
|
|
16
|
+
// @ts-expect-error Slot does not own an arbitrary options bag.
|
|
17
|
+
const slot: NavigatorNode = { type: 'slot', options: { arbitrary: true }, routes: [] };
|
|
18
|
+
// @ts-expect-error Experimental Stack has no presentation options.
|
|
19
|
+
const experimental: StackImplementationConfig = {
|
|
20
|
+
implementation: 'experimental',
|
|
21
|
+
options: { presentation: 'modal' },
|
|
22
|
+
};
|
|
23
|
+
// @ts-expect-error Fixed custom tabs do not accept a registered custom presentation id.
|
|
24
|
+
const tabs: CustomTabsConfig = {
|
|
25
|
+
implementation: 'custom',
|
|
26
|
+
presentation: 'sidebar',
|
|
27
|
+
customPresentationId: 'workspace-tabs',
|
|
28
|
+
};
|
|
29
|
+
// @ts-expect-error Custom navigator configuration must be serializable manifest values.
|
|
30
|
+
const custom: CustomNavigatorNode = {
|
|
31
|
+
type: 'custom',
|
|
32
|
+
navigatorId: 'workspace',
|
|
33
|
+
config: { callback: () => undefined },
|
|
34
|
+
routes: [],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
expect([slot, experimental, tabs, custom]).toHaveLength(4);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('app navigator manifest stack validation', () => {
|
|
42
|
+
it('rejects the removed untyped options bag and unsupported stack branch fields', () => {
|
|
43
|
+
expect(isAppNavigatorManifest({ type: 'slot', options: { arbitrary: true }, routes: [] })).toBe(
|
|
44
|
+
false,
|
|
45
|
+
);
|
|
46
|
+
expect(
|
|
47
|
+
isAppNavigatorManifest({
|
|
48
|
+
type: 'stack',
|
|
49
|
+
implementation: 'experimental',
|
|
50
|
+
options: { presentation: 'modal' },
|
|
51
|
+
routes: [],
|
|
52
|
+
}),
|
|
53
|
+
).toBe(false);
|
|
54
|
+
expect(
|
|
55
|
+
isAppNavigatorManifest({
|
|
56
|
+
type: 'stack',
|
|
57
|
+
implementation: 'javascript',
|
|
58
|
+
options: { presentation: 'formSheet' },
|
|
59
|
+
routes: [],
|
|
60
|
+
}),
|
|
61
|
+
).toBe(false);
|
|
62
|
+
expect(
|
|
63
|
+
isAppNavigatorManifest({
|
|
64
|
+
type: 'stack',
|
|
65
|
+
implementation: 'native',
|
|
66
|
+
minimizeBehavior: 'never',
|
|
67
|
+
routes: [],
|
|
68
|
+
}),
|
|
69
|
+
).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('app navigator manifest sheet validation', () => {
|
|
74
|
+
it('validates native form-sheet detents and their presentation discriminant', () => {
|
|
75
|
+
for (const sheetAllowedDetents of [[], [0.8, 0.4], [0.4, 0.4], [0], [1.1], [Number.NaN]]) {
|
|
76
|
+
expect(
|
|
77
|
+
isAppNavigatorManifest({
|
|
78
|
+
type: 'stack',
|
|
79
|
+
options: { presentation: 'formSheet', sheetAllowedDetents },
|
|
80
|
+
routes: [],
|
|
81
|
+
}),
|
|
82
|
+
).toBe(false);
|
|
83
|
+
}
|
|
84
|
+
expect(
|
|
85
|
+
isAppNavigatorManifest({
|
|
86
|
+
type: 'stack',
|
|
87
|
+
options: { presentation: 'modal', sheetGrabberVisible: true },
|
|
88
|
+
routes: [],
|
|
89
|
+
}),
|
|
90
|
+
).toBe(false);
|
|
91
|
+
expect(
|
|
92
|
+
isAppNavigatorManifest({
|
|
93
|
+
type: 'stack',
|
|
94
|
+
routes: [
|
|
95
|
+
{
|
|
96
|
+
name: 'compose',
|
|
97
|
+
stackOptions: {
|
|
98
|
+
presentation: 'formSheet',
|
|
99
|
+
sheetAllowedDetents: 'fitToContents',
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
}),
|
|
104
|
+
).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('app navigator manifest custom validation', () => {
|
|
109
|
+
it('rejects non-JSON and cyclic custom navigator configuration', () => {
|
|
110
|
+
const cyclicConfig: Record<string, unknown> = {};
|
|
111
|
+
cyclicConfig.self = cyclicConfig;
|
|
112
|
+
|
|
113
|
+
for (const invalidConfig of [
|
|
114
|
+
{ callback: () => undefined },
|
|
115
|
+
{ token: Symbol('token') },
|
|
116
|
+
{ count: Number.NaN },
|
|
117
|
+
{ count: Number.POSITIVE_INFINITY },
|
|
118
|
+
cyclicConfig,
|
|
119
|
+
]) {
|
|
120
|
+
expect(
|
|
121
|
+
isAppNavigatorManifest({
|
|
122
|
+
type: 'custom',
|
|
123
|
+
navigatorId: 'workspace',
|
|
124
|
+
config: invalidConfig,
|
|
125
|
+
routes: [],
|
|
126
|
+
}),
|
|
127
|
+
).toBe(false);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('rejects incomplete Split View bindings', () => {
|
|
132
|
+
expect(
|
|
133
|
+
isAppNavigatorManifest({
|
|
134
|
+
type: 'split-view',
|
|
135
|
+
columns: {},
|
|
136
|
+
routes: [],
|
|
137
|
+
}),
|
|
138
|
+
).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('app navigator manifest package boundary', () => {
|
|
143
|
+
it('publishes the focused navigator contract subpath', async () => {
|
|
144
|
+
const packageJson = JSON.parse(await readFile(join(process.cwd(), 'package.json'), 'utf8')) as {
|
|
145
|
+
exports?: Record<string, { default?: string; types?: string }>;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
expect(packageJson.exports?.['./navigator']).toEqual({
|
|
149
|
+
types: './dist/navigator.d.ts',
|
|
150
|
+
default: './dist/navigator.js',
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
});
|
package/src/navigator.test.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
|
|
4
1
|
import { describe, expect, it } from 'bun:test';
|
|
5
2
|
|
|
6
3
|
import { isAppNavigatorManifest } from './appManifest/screens';
|
|
7
4
|
import {
|
|
8
5
|
type AppNavigatorManifest,
|
|
9
6
|
NAVIGATOR_PRESETS,
|
|
7
|
+
NAVIGATOR_TYPES,
|
|
8
|
+
type NavigatorNode,
|
|
9
|
+
type StackImplementationConfig,
|
|
10
10
|
type TabsNavigatorConfig,
|
|
11
11
|
} from './navigator';
|
|
12
12
|
|
|
@@ -28,8 +28,66 @@ function createAdaptiveTabs(): AppNavigatorManifest {
|
|
|
28
28
|
};
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
const VALID_NAVIGATOR_NODES = [
|
|
32
|
+
{ type: 'slot', routes: [] },
|
|
33
|
+
{
|
|
34
|
+
type: 'stack',
|
|
35
|
+
implementation: 'native',
|
|
36
|
+
options: {
|
|
37
|
+
presentation: 'formSheet',
|
|
38
|
+
sheetAllowedDetents: [0.4, 0.8],
|
|
39
|
+
sheetGrabberVisible: true,
|
|
40
|
+
},
|
|
41
|
+
routes: [],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
type: 'stack',
|
|
45
|
+
implementation: 'javascript',
|
|
46
|
+
options: { presentation: 'transparentModal', headerShown: false },
|
|
47
|
+
routes: [],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
type: 'stack',
|
|
51
|
+
implementation: 'experimental',
|
|
52
|
+
options: { title: 'Profile', headerTransparent: true },
|
|
53
|
+
routes: [],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
type: 'tabs',
|
|
57
|
+
implementation: 'native',
|
|
58
|
+
minimizeBehavior: 'onScrollDown',
|
|
59
|
+
bottomAccessory: { screenId: 'now-playing' },
|
|
60
|
+
routes: [],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
type: 'drawer',
|
|
64
|
+
options: { drawerPosition: 'right', drawerType: 'permanent', swipeEnabled: false },
|
|
65
|
+
routes: [],
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
type: 'split-view',
|
|
69
|
+
columns: {
|
|
70
|
+
primary: { screenId: 'sidebar' },
|
|
71
|
+
supplementary: { screenId: 'inspector-list' },
|
|
72
|
+
},
|
|
73
|
+
inspector: { screenId: 'inspector' },
|
|
74
|
+
topColumnForCollapsing: 'secondary',
|
|
75
|
+
routes: [{ name: 'detail', screenId: 'detail' }],
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
type: 'custom',
|
|
79
|
+
navigatorId: 'workspace',
|
|
80
|
+
config: { animation: 'spring', thresholds: [0.25, 0.75], enabled: true },
|
|
81
|
+
routes: [],
|
|
82
|
+
},
|
|
83
|
+
] as const satisfies readonly NavigatorNode[];
|
|
84
|
+
|
|
31
85
|
describe('app navigator manifest topology', () => {
|
|
32
86
|
it('keeps canonical topology presets finite and authorable', () => {
|
|
87
|
+
expect(NAVIGATOR_TYPES).toEqual(['slot', 'stack', 'tabs', 'drawer', 'split-view', 'custom']);
|
|
88
|
+
expect(NAVIGATOR_PRESETS).toContain('slot');
|
|
89
|
+
expect(NAVIGATOR_PRESETS).toContain('split-view');
|
|
90
|
+
expect(NAVIGATOR_PRESETS).toContain('custom');
|
|
33
91
|
expect(NAVIGATOR_PRESETS).toContain('root-stack-tabs-stack');
|
|
34
92
|
expect(NAVIGATOR_PRESETS).toContain('root-stack-drawer-tabs-stack');
|
|
35
93
|
});
|
|
@@ -38,6 +96,29 @@ describe('app navigator manifest topology', () => {
|
|
|
38
96
|
expect(isAppNavigatorManifest(createAdaptiveTabs())).toBe(true);
|
|
39
97
|
});
|
|
40
98
|
|
|
99
|
+
it('accepts SVG media icon references without a provider', () => {
|
|
100
|
+
const navigator = createAdaptiveTabs();
|
|
101
|
+
navigator.routes[0] = {
|
|
102
|
+
...navigator.routes[0],
|
|
103
|
+
icon: { source: { mediaId: 'navigation-home' } },
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
expect(isAppNavigatorManifest(navigator)).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('rejects icon definitions that mix named and media sources', () => {
|
|
110
|
+
const navigator = createAdaptiveTabs();
|
|
111
|
+
navigator.routes[0] = {
|
|
112
|
+
...navigator.routes[0],
|
|
113
|
+
icon: {
|
|
114
|
+
name: 'home-outline',
|
|
115
|
+
source: { mediaId: 'navigation-home' },
|
|
116
|
+
} as never,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
expect(isAppNavigatorManifest(navigator)).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
41
122
|
it('treats omitted tabs implementation as the canonical adaptive default', () => {
|
|
42
123
|
const tabs: TabsNavigatorConfig = { type: 'tabs' };
|
|
43
124
|
const navigator: AppNavigatorManifest = { ...tabs, routes: [] };
|
|
@@ -46,6 +127,31 @@ describe('app navigator manifest topology', () => {
|
|
|
46
127
|
});
|
|
47
128
|
});
|
|
48
129
|
|
|
130
|
+
describe('app navigator manifest node variants', () => {
|
|
131
|
+
it('accepts every typed navigator node variant', () => {
|
|
132
|
+
expect(VALID_NAVIGATOR_NODES.every(isAppNavigatorManifest)).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('accepts stack defaults and per-platform implementation overrides', () => {
|
|
136
|
+
const experimental = {
|
|
137
|
+
implementation: 'experimental',
|
|
138
|
+
options: { headerShown: false },
|
|
139
|
+
} as const satisfies StackImplementationConfig;
|
|
140
|
+
|
|
141
|
+
expect(
|
|
142
|
+
isAppNavigatorManifest({
|
|
143
|
+
type: 'stack',
|
|
144
|
+
routes: [],
|
|
145
|
+
defaults: { stack: { implementation: 'native' } },
|
|
146
|
+
platforms: {
|
|
147
|
+
ios: { stack: experimental },
|
|
148
|
+
web: { stack: { implementation: 'javascript', options: { presentation: 'card' } } },
|
|
149
|
+
},
|
|
150
|
+
}),
|
|
151
|
+
).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
49
155
|
describe('app navigator manifest custom presentation', () => {
|
|
50
156
|
it('accepts fixed and registered custom Web presentations', () => {
|
|
51
157
|
expect(
|
|
@@ -89,6 +195,50 @@ describe('app navigator manifest custom presentation', () => {
|
|
|
89
195
|
});
|
|
90
196
|
});
|
|
91
197
|
|
|
198
|
+
describe('app navigator manifest tabs branch validation', () => {
|
|
199
|
+
it('rejects fields from incompatible tab branches', () => {
|
|
200
|
+
expect(
|
|
201
|
+
isAppNavigatorManifest({
|
|
202
|
+
type: 'tabs',
|
|
203
|
+
implementation: 'native',
|
|
204
|
+
presentation: 'bottom',
|
|
205
|
+
routes: [],
|
|
206
|
+
}),
|
|
207
|
+
).toBe(false);
|
|
208
|
+
|
|
209
|
+
expect(
|
|
210
|
+
isAppNavigatorManifest({
|
|
211
|
+
type: 'tabs',
|
|
212
|
+
implementation: 'javascript',
|
|
213
|
+
presentation: 'bottom',
|
|
214
|
+
native: { implementation: 'native' },
|
|
215
|
+
routes: [],
|
|
216
|
+
}),
|
|
217
|
+
).toBe(false);
|
|
218
|
+
|
|
219
|
+
expect(
|
|
220
|
+
isAppNavigatorManifest({
|
|
221
|
+
type: 'tabs',
|
|
222
|
+
implementation: 'custom',
|
|
223
|
+
presentation: 'sidebar',
|
|
224
|
+
customPresentationId: 'must-not-apply',
|
|
225
|
+
routes: [],
|
|
226
|
+
}),
|
|
227
|
+
).toBe(false);
|
|
228
|
+
|
|
229
|
+
expect(
|
|
230
|
+
isAppNavigatorManifest({
|
|
231
|
+
type: 'tabs',
|
|
232
|
+
implementation: 'custom',
|
|
233
|
+
presentation: 'custom',
|
|
234
|
+
customPresentationId: 'workspace-tabs',
|
|
235
|
+
responsive: { compact: 'bottom', expanded: 'sidebar' },
|
|
236
|
+
routes: [],
|
|
237
|
+
}),
|
|
238
|
+
).toBe(false);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
92
242
|
describe('app navigator manifest composition', () => {
|
|
93
243
|
it('keeps nested topology separate from app-level flow metadata', () => {
|
|
94
244
|
expect(
|
|
@@ -108,14 +258,30 @@ describe('app navigator manifest composition', () => {
|
|
|
108
258
|
).toBe(true);
|
|
109
259
|
});
|
|
110
260
|
|
|
111
|
-
it('
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
261
|
+
it('preserves authored route names, labels, paths, guards, and visibility without inference', () => {
|
|
262
|
+
const navigator = {
|
|
263
|
+
type: 'stack',
|
|
264
|
+
routes: [
|
|
265
|
+
{
|
|
266
|
+
name: '(app)',
|
|
267
|
+
label: 'Application',
|
|
268
|
+
guards: ['authenticated'],
|
|
269
|
+
showInPrimaryNavigation: false,
|
|
270
|
+
navigator: {
|
|
271
|
+
type: 'tabs',
|
|
272
|
+
routes: [{ name: 'learn', path: '/learn', label: 'Train', screenId: 'learn' }],
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
],
|
|
276
|
+
} as const satisfies AppNavigatorManifest;
|
|
115
277
|
|
|
116
|
-
expect(
|
|
117
|
-
|
|
118
|
-
|
|
278
|
+
expect(isAppNavigatorManifest(navigator)).toBe(true);
|
|
279
|
+
expect(JSON.parse(JSON.stringify(navigator))).toEqual(navigator);
|
|
280
|
+
expect(navigator.routes[0].navigator.routes[0]).toEqual({
|
|
281
|
+
name: 'learn',
|
|
282
|
+
path: '/learn',
|
|
283
|
+
label: 'Train',
|
|
284
|
+
screenId: 'learn',
|
|
119
285
|
});
|
|
120
286
|
});
|
|
121
287
|
});
|