@ankhorage/contracts 10.1.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.
@@ -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
+ });
@@ -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
  });
@@ -69,6 +127,31 @@ describe('app navigator manifest topology', () => {
69
127
  });
70
128
  });
71
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
+
72
155
  describe('app navigator manifest custom presentation', () => {
73
156
  it('accepts fixed and registered custom Web presentations', () => {
74
157
  expect(
@@ -112,6 +195,50 @@ describe('app navigator manifest custom presentation', () => {
112
195
  });
113
196
  });
114
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
+
115
242
  describe('app navigator manifest composition', () => {
116
243
  it('keeps nested topology separate from app-level flow metadata', () => {
117
244
  expect(
@@ -131,14 +258,30 @@ describe('app navigator manifest composition', () => {
131
258
  ).toBe(true);
132
259
  });
133
260
 
134
- it('publishes the focused navigator contract subpath', async () => {
135
- const packageJson = JSON.parse(await readFile(join(process.cwd(), 'package.json'), 'utf8')) as {
136
- exports?: Record<string, { default?: string; types?: string }>;
137
- };
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;
138
277
 
139
- expect(packageJson.exports?.['./navigator']).toEqual({
140
- types: './dist/navigator.d.ts',
141
- default: './dist/navigator.js',
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',
142
285
  });
143
286
  });
144
287
  });
package/src/navigator.ts CHANGED
@@ -1,9 +1,10 @@
1
- import type { IconSpec } from './types';
1
+ import type { IconSpec, ManifestValue } from './types';
2
2
 
3
- export const NAVIGATOR_TYPES = ['stack', 'tabs', 'drawer'] as const;
3
+ export const NAVIGATOR_TYPES = ['slot', 'stack', 'tabs', 'drawer', 'split-view', 'custom'] as const;
4
4
  export type NavigatorType = (typeof NAVIGATOR_TYPES)[number];
5
5
 
6
6
  export const NAVIGATOR_PRESETS = [
7
+ 'slot',
7
8
  'stack',
8
9
  'tabs',
9
10
  'tabs-stack',
@@ -17,9 +18,82 @@ export const NAVIGATOR_PRESETS = [
17
18
  'root-stack-drawer-stack',
18
19
  'root-stack-drawer-tabs',
19
20
  'root-stack-drawer-tabs-stack',
21
+ 'split-view',
22
+ 'custom',
20
23
  ] as const;
21
24
  export type NavigatorPreset = (typeof NAVIGATOR_PRESETS)[number];
22
25
 
26
+ export const STACK_IMPLEMENTATIONS = ['native', 'javascript', 'experimental'] as const;
27
+ export type StackImplementation = (typeof STACK_IMPLEMENTATIONS)[number];
28
+
29
+ export const STACK_PRESENTATIONS = [
30
+ 'card',
31
+ 'modal',
32
+ 'transparentModal',
33
+ 'containedModal',
34
+ 'containedTransparentModal',
35
+ 'fullScreenModal',
36
+ 'formSheet',
37
+ ] as const;
38
+ export type StackPresentation = (typeof STACK_PRESENTATIONS)[number];
39
+
40
+ export const JAVASCRIPT_STACK_PRESENTATIONS = ['card', 'modal', 'transparentModal'] as const;
41
+ export type JavaScriptStackPresentation = (typeof JAVASCRIPT_STACK_PRESENTATIONS)[number];
42
+
43
+ export interface StackHeaderOptions {
44
+ title?: string;
45
+ headerShown?: boolean;
46
+ headerTransparent?: boolean;
47
+ headerBackVisible?: boolean;
48
+ }
49
+
50
+ export type StackScreenOptions = StackHeaderOptions &
51
+ (
52
+ | {
53
+ presentation?: Exclude<StackPresentation, 'formSheet'>;
54
+ sheetAllowedDetents?: never;
55
+ sheetGrabberVisible?: never;
56
+ }
57
+ | {
58
+ presentation: 'formSheet';
59
+ sheetAllowedDetents?: 'fitToContents' | number[];
60
+ sheetGrabberVisible?: boolean;
61
+ }
62
+ );
63
+
64
+ export type JavaScriptStackScreenOptions = StackHeaderOptions & {
65
+ presentation?: JavaScriptStackPresentation;
66
+ };
67
+
68
+ export type StackImplementationConfig =
69
+ | {
70
+ /** Omission selects the stable native stack implementation. */
71
+ implementation?: 'native';
72
+ options?: StackScreenOptions;
73
+ }
74
+ | {
75
+ implementation: 'javascript';
76
+ options?: JavaScriptStackScreenOptions;
77
+ }
78
+ | {
79
+ /** Expo Router Experimental Stack is alpha, native-only, and available from SDK 56. */
80
+ implementation: 'experimental';
81
+ options?: StackHeaderOptions;
82
+ };
83
+
84
+ export const DRAWER_POSITIONS = ['left', 'right'] as const;
85
+ export type DrawerPosition = (typeof DRAWER_POSITIONS)[number];
86
+
87
+ export const DRAWER_TYPES = ['front', 'back', 'slide', 'permanent'] as const;
88
+ export type DrawerType = (typeof DRAWER_TYPES)[number];
89
+
90
+ export interface DrawerNavigatorOptions {
91
+ drawerPosition?: DrawerPosition;
92
+ drawerType?: DrawerType;
93
+ swipeEnabled?: boolean;
94
+ headerShown?: boolean;
95
+ }
96
+
23
97
  export const FIXED_CUSTOM_TABS_PRESENTATIONS = ['bottom', 'top', 'rail', 'sidebar'] as const;
24
98
  export type FixedCustomTabsPresentation = (typeof FIXED_CUSTOM_TABS_PRESENTATIONS)[number];
25
99
 
@@ -33,22 +107,54 @@ export type CustomTabsPresentation = (typeof CUSTOM_TABS_PRESENTATIONS)[number];
33
107
  export const JAVASCRIPT_TABS_PRESENTATIONS = ['bottom', 'top'] as const;
34
108
  export type JavaScriptTabsPresentation = (typeof JAVASCRIPT_TABS_PRESENTATIONS)[number];
35
109
 
110
+ export const NATIVE_TABS_MINIMIZE_BEHAVIORS = [
111
+ 'automatic',
112
+ 'never',
113
+ 'onScrollDown',
114
+ 'onScrollUp',
115
+ ] as const;
116
+ export type NativeTabsMinimizeBehavior = (typeof NATIVE_TABS_MINIMIZE_BEHAVIORS)[number];
117
+
36
118
  export interface ResponsiveTabsPresentation {
37
119
  compact: FixedCustomTabsPresentation;
38
120
  medium?: FixedCustomTabsPresentation;
39
121
  expanded: FixedCustomTabsPresentation;
40
122
  }
41
123
 
42
- export interface CustomTabsConfig {
124
+ export type CustomTabsPresentationConfig =
125
+ | {
126
+ presentation: FixedCustomTabsPresentation;
127
+ responsive?: never;
128
+ customPresentationId?: never;
129
+ }
130
+ | {
131
+ presentation: 'responsive';
132
+ responsive: ResponsiveTabsPresentation;
133
+ customPresentationId?: never;
134
+ }
135
+ | {
136
+ presentation: 'custom';
137
+ responsive?: never;
138
+ /** Serializable registered presentation id. */
139
+ customPresentationId: string;
140
+ };
141
+
142
+ export type CustomTabsConfig = {
43
143
  implementation: 'custom';
44
- presentation: CustomTabsPresentation;
45
- responsive?: ResponsiveTabsPresentation;
46
- /** Serializable registered presentation id used when `presentation` is `custom`. */
47
- customPresentationId?: string;
144
+ } & CustomTabsPresentationConfig;
145
+
146
+ export type CustomTabsWebConfig = CustomTabsPresentationConfig;
147
+
148
+ export interface NavigatorScreenReference {
149
+ screenId: string;
48
150
  }
49
151
 
50
152
  export interface NativeTabsConfig {
51
153
  implementation: 'native';
154
+ /** Expo Router Native Tabs is alpha; minimizing is available on iOS 26+ from SDK 55. */
155
+ minimizeBehavior?: NativeTabsMinimizeBehavior;
156
+ /** Registered screen rendered through NativeTabs.BottomAccessory, available from SDK 55. */
157
+ bottomAccessory?: NavigatorScreenReference;
52
158
  }
53
159
 
54
160
  export interface JavaScriptTabsConfig {
@@ -62,7 +168,7 @@ export interface AdaptiveTabsConfig {
62
168
  /** Android/iOS branch. Expo Router may expose this implementation as unstable. */
63
169
  native?: NativeTabsConfig;
64
170
  /** Web branch rendered through headless custom tabs. */
65
- web?: Omit<CustomTabsConfig, 'implementation'>;
171
+ web?: CustomTabsWebConfig;
66
172
  }
67
173
 
68
174
  export type TabsImplementationConfig =
@@ -71,16 +177,19 @@ export type TabsImplementationConfig =
71
177
  interface NavigatorNodeBase {
72
178
  initialRouteName?: string;
73
179
  routes: RouteDefinition[];
74
- /** Typed upstream options can be layered by the owning Navigator package. */
75
- options?: Record<string, unknown>;
76
180
  }
77
181
 
78
- export interface StackNavigatorNode extends NavigatorNodeBase {
79
- type: 'stack';
182
+ export interface SlotNavigatorNode extends NavigatorNodeBase {
183
+ type: 'slot';
80
184
  }
81
185
 
186
+ export type StackNavigatorNode = NavigatorNodeBase & {
187
+ type: 'stack';
188
+ } & StackImplementationConfig;
189
+
82
190
  export interface DrawerNavigatorNode extends NavigatorNodeBase {
83
191
  type: 'drawer';
192
+ options?: DrawerNavigatorOptions;
84
193
  }
85
194
 
86
195
  export type TabsNavigatorConfig = {
@@ -89,7 +198,30 @@ export type TabsNavigatorConfig = {
89
198
 
90
199
  export type TabsNavigatorNode = NavigatorNodeBase & TabsNavigatorConfig;
91
200
 
92
- export type NavigatorNode = DrawerNavigatorNode | StackNavigatorNode | TabsNavigatorNode;
201
+ export interface SplitViewNavigatorNode extends NavigatorNodeBase {
202
+ type: 'split-view';
203
+ columns: {
204
+ primary: NavigatorScreenReference;
205
+ supplementary?: NavigatorScreenReference;
206
+ };
207
+ inspector?: NavigatorScreenReference;
208
+ /** Split View is alpha and iOS-only; other platforms fall back to Slot. */
209
+ topColumnForCollapsing?: 'primary' | 'supplementary' | 'secondary';
210
+ }
211
+
212
+ export interface CustomNavigatorNode extends NavigatorNodeBase {
213
+ type: 'custom';
214
+ navigatorId: string;
215
+ config?: Readonly<Record<string, ManifestValue>>;
216
+ }
217
+
218
+ export type NavigatorNode =
219
+ | CustomNavigatorNode
220
+ | DrawerNavigatorNode
221
+ | SlotNavigatorNode
222
+ | SplitViewNavigatorNode
223
+ | StackNavigatorNode
224
+ | TabsNavigatorNode;
93
225
 
94
226
  export interface RouteDefinition {
95
227
  name: string;
@@ -100,6 +232,8 @@ export interface RouteDefinition {
100
232
  showInPrimaryNavigation?: boolean;
101
233
  guards?: string[];
102
234
  screenId?: string;
235
+ /** Presentation applied by the resolved parent stack implementation. */
236
+ stackOptions?: StackScreenOptions;
103
237
  navigator?: NavigatorNode;
104
238
  }
105
239
 
@@ -110,10 +244,12 @@ export interface NavigatorFlows {
110
244
 
111
245
  export interface NavigatorDefaults {
112
246
  tabs?: TabsImplementationConfig;
247
+ stack?: StackImplementationConfig;
113
248
  }
114
249
 
115
250
  export interface NavigatorPlatformConfig {
116
251
  tabs?: TabsImplementationConfig;
252
+ stack?: StackImplementationConfig;
117
253
  }
118
254
 
119
255
  export interface NavigatorPlatforms {