@ankhorage/zora 3.2.0 → 3.3.2

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 (107) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/components/card/meta.d.ts +1 -1
  3. package/dist/foundation/meta.d.ts +4 -4
  4. package/dist/index.d.ts +5 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +2 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/layout/screen/meta.d.ts +1 -1
  9. package/dist/layout/screen-section/meta.d.ts +1 -1
  10. package/dist/metadata/allowedChildren.d.ts +4 -3
  11. package/dist/metadata/allowedChildren.d.ts.map +1 -1
  12. package/dist/metadata/allowedChildren.js +14 -0
  13. package/dist/metadata/allowedChildren.js.map +1 -1
  14. package/dist/metadata/bindableComponentMeta.d.ts +2 -2
  15. package/dist/metadata/componentMeta.d.ts.map +1 -1
  16. package/dist/metadata/componentMeta.js +4 -0
  17. package/dist/metadata/componentMeta.js.map +1 -1
  18. package/dist/metadata/index.d.ts +1 -1
  19. package/dist/metadata/index.d.ts.map +1 -1
  20. package/dist/metadata/index.js.map +1 -1
  21. package/dist/metadata/types.d.ts +6 -0
  22. package/dist/metadata/types.d.ts.map +1 -1
  23. package/dist/metadata/types.js.map +1 -1
  24. package/dist/patterns/content-rail/ContentRail.d.ts +10 -0
  25. package/dist/patterns/content-rail/ContentRail.d.ts.map +1 -0
  26. package/dist/patterns/content-rail/ContentRail.js +175 -0
  27. package/dist/patterns/content-rail/ContentRail.js.map +1 -0
  28. package/dist/patterns/content-rail/index.d.ts +3 -0
  29. package/dist/patterns/content-rail/index.d.ts.map +1 -0
  30. package/dist/patterns/content-rail/index.js +2 -0
  31. package/dist/patterns/content-rail/index.js.map +1 -0
  32. package/dist/patterns/content-rail/meta.d.ts +143 -0
  33. package/dist/patterns/content-rail/meta.d.ts.map +1 -0
  34. package/dist/patterns/content-rail/meta.js +130 -0
  35. package/dist/patterns/content-rail/meta.js.map +1 -0
  36. package/dist/patterns/content-rail/resolveContentRailItemWidth.d.ts +10 -0
  37. package/dist/patterns/content-rail/resolveContentRailItemWidth.d.ts.map +1 -0
  38. package/dist/patterns/content-rail/resolveContentRailItemWidth.js +25 -0
  39. package/dist/patterns/content-rail/resolveContentRailItemWidth.js.map +1 -0
  40. package/dist/patterns/content-rail/resolveContentRailPhysicalOffset.d.ts +6 -0
  41. package/dist/patterns/content-rail/resolveContentRailPhysicalOffset.d.ts.map +1 -0
  42. package/dist/patterns/content-rail/resolveContentRailPhysicalOffset.js +5 -0
  43. package/dist/patterns/content-rail/resolveContentRailPhysicalOffset.js.map +1 -0
  44. package/dist/patterns/content-rail/resolveContentRailState.d.ts +18 -0
  45. package/dist/patterns/content-rail/resolveContentRailState.d.ts.map +1 -0
  46. package/dist/patterns/content-rail/resolveContentRailState.js +21 -0
  47. package/dist/patterns/content-rail/resolveContentRailState.js.map +1 -0
  48. package/dist/patterns/content-rail/types.d.ts +31 -0
  49. package/dist/patterns/content-rail/types.d.ts.map +1 -0
  50. package/dist/patterns/content-rail/types.js +2 -0
  51. package/dist/patterns/content-rail/types.js.map +1 -0
  52. package/dist/patterns/content-rail/useContentRailReducedMotion.d.ts +3 -0
  53. package/dist/patterns/content-rail/useContentRailReducedMotion.d.ts.map +1 -0
  54. package/dist/patterns/content-rail/useContentRailReducedMotion.js +21 -0
  55. package/dist/patterns/content-rail/useContentRailReducedMotion.js.map +1 -0
  56. package/dist/patterns/message-bubble/meta.d.ts +1 -1
  57. package/dist/patterns/missing-element/MissingElement.d.ts +10 -0
  58. package/dist/patterns/missing-element/MissingElement.d.ts.map +1 -0
  59. package/dist/patterns/missing-element/MissingElement.js +41 -0
  60. package/dist/patterns/missing-element/MissingElement.js.map +1 -0
  61. package/dist/patterns/missing-element/index.d.ts +3 -0
  62. package/dist/patterns/missing-element/index.d.ts.map +1 -0
  63. package/dist/patterns/missing-element/index.js +2 -0
  64. package/dist/patterns/missing-element/index.js.map +1 -0
  65. package/dist/patterns/missing-element/meta.d.ts +68 -0
  66. package/dist/patterns/missing-element/meta.d.ts.map +1 -0
  67. package/dist/patterns/missing-element/meta.js +59 -0
  68. package/dist/patterns/missing-element/meta.js.map +1 -0
  69. package/dist/patterns/missing-element/types.d.ts +9 -0
  70. package/dist/patterns/missing-element/types.d.ts.map +1 -0
  71. package/dist/patterns/missing-element/types.js +2 -0
  72. package/dist/patterns/missing-element/types.js.map +1 -0
  73. package/dist/patterns/notice/meta.d.ts +1 -1
  74. package/dist/patterns/panel/meta.d.ts +1 -1
  75. package/dist/patterns/post-card/meta.d.ts +1 -1
  76. package/dist/patterns/reader/meta.d.ts +2 -2
  77. package/dist/patterns/scanner/meta.d.ts +2 -2
  78. package/dist/registry.d.ts.map +1 -1
  79. package/dist/registry.js +4 -0
  80. package/dist/registry.js.map +1 -1
  81. package/package.json +3 -3
  82. package/src/expo57Migration.test.ts +16 -1
  83. package/src/index.ts +13 -0
  84. package/src/interactionPolicy.test.ts +22 -0
  85. package/src/metadata/allowedChildren.ts +15 -0
  86. package/src/metadata/componentMeta.test.ts +50 -0
  87. package/src/metadata/componentMeta.ts +4 -0
  88. package/src/metadata/index.ts +1 -0
  89. package/src/metadata/types.ts +7 -0
  90. package/src/patterns/content-rail/ContentRail.test.ts +44 -0
  91. package/src/patterns/content-rail/ContentRail.tsx +245 -0
  92. package/src/patterns/content-rail/index.ts +10 -0
  93. package/src/patterns/content-rail/meta.ts +133 -0
  94. package/src/patterns/content-rail/resolveContentRailItemWidth.test.ts +77 -0
  95. package/src/patterns/content-rail/resolveContentRailItemWidth.ts +39 -0
  96. package/src/patterns/content-rail/resolveContentRailPhysicalOffset.test.ts +29 -0
  97. package/src/patterns/content-rail/resolveContentRailPhysicalOffset.ts +12 -0
  98. package/src/patterns/content-rail/resolveContentRailState.test.ts +68 -0
  99. package/src/patterns/content-rail/resolveContentRailState.ts +36 -0
  100. package/src/patterns/content-rail/types.ts +35 -0
  101. package/src/patterns/content-rail/useContentRailReducedMotion.ts +28 -0
  102. package/src/patterns/missing-element/MissingElement.test.ts +66 -0
  103. package/src/patterns/missing-element/MissingElement.tsx +73 -0
  104. package/src/patterns/missing-element/index.ts +2 -0
  105. package/src/patterns/missing-element/meta.ts +62 -0
  106. package/src/patterns/missing-element/types.ts +9 -0
  107. package/src/registry.ts +4 -0
@@ -87,19 +87,34 @@ describe('portable ZORA package boundary', () => {
87
87
 
88
88
  describe('Expo 57 example boundary', () => {
89
89
  test('uses Renovate-safe dependency range styles across every Expo example', () => {
90
+ const expoVersions = new Set<string>();
91
+
90
92
  for (const directory of EXPO_EXAMPLE_DIRS) {
91
93
  const packageJson = readExamplePackage(directory);
92
94
  const dependencies = readRecord(packageJson, 'dependencies');
93
95
  const development = readRecord(packageJson, 'devDependencies');
96
+ const expoVersion = readValue(dependencies, 'expo');
94
97
 
95
98
  expect(readValue(dependencies, '@ankhorage/zora')).toMatch(CARET_SEMVER_RANGE);
96
- expect(dependencies.expo).toMatch(EXACT_SEMVER_VERSION);
99
+ expect(expoVersion).toMatch(EXACT_SEMVER_VERSION);
97
100
  expect(dependencies.react).toMatch(EXACT_SEMVER_VERSION);
98
101
  expect(readValue(dependencies, 'react-native')).toMatch(EXACT_SEMVER_VERSION);
99
102
  expect(readValue(dependencies, 'react-native-web')).toMatch(TILDE_SEMVER_RANGE);
100
103
  expect(development.typescript).toMatch(TILDE_SEMVER_RANGE);
101
104
  expect(readValue(dependencies, '@expo/vector-icons')).toBeUndefined();
105
+
106
+ if (typeof expoVersion === 'string') {
107
+ expoVersions.add(expoVersion);
108
+ }
102
109
  }
110
+
111
+ expect(expoVersions.size).toBe(1);
112
+ const [expoVersion] = expoVersions;
113
+ const scaffoldSource = readFileSync(
114
+ join(ROOT, 'scripts', 'scaffold-zora-example-app.ts'),
115
+ 'utf8',
116
+ );
117
+ expect(scaffoldSource).toContain(`expo: '${expoVersion}',`);
103
118
  });
104
119
 
105
120
  test('registers all scoped RNVI packages in each Expo app config', () => {
package/src/index.ts CHANGED
@@ -214,6 +214,7 @@ export type {
214
214
  ZoraComponentEventPayloadFieldType,
215
215
  ZoraComponentEventPayloadKind,
216
216
  ZoraComponentI18nMeta,
217
+ ZoraComponentManifestPolicy,
217
218
  ZoraComponentMeta,
218
219
  ZoraComponentMetaRegistry,
219
220
  ZoraComponentPropArrayItemSchema,
@@ -275,6 +276,16 @@ export type {
275
276
  export { CollectionEditor } from './patterns/collection-editor';
276
277
  export type { ConfirmDialogProps } from './patterns/confirm-dialog';
277
278
  export { ConfirmDialog } from './patterns/confirm-dialog';
279
+ export type {
280
+ ContentRailControlPressEvent,
281
+ ContentRailDirection,
282
+ ContentRailItemSize,
283
+ ContentRailMotion,
284
+ ContentRailProps,
285
+ ContentRailSpacing,
286
+ ContentRailVisibleRangeChangeEvent,
287
+ } from './patterns/content-rail';
288
+ export { ContentRail } from './patterns/content-rail';
278
289
  export type { DisclosureSectionProps } from './patterns/disclosure-section';
279
290
  export { DisclosureSection } from './patterns/disclosure-section';
280
291
  export type { EmptyStateAction, EmptyStateProps } from './patterns/empty-state';
@@ -313,6 +324,8 @@ export type {
313
324
  MessageBubbleStatus,
314
325
  } from './patterns/message-bubble';
315
326
  export { MessageBubble } from './patterns/message-bubble';
327
+ export type { MissingElementProps } from './patterns/missing-element';
328
+ export { MissingElement } from './patterns/missing-element';
316
329
  export type { NoticeProps } from './patterns/notice';
317
330
  export { Notice } from './patterns/notice';
318
331
  export type { PanelProps } from './patterns/panel';
@@ -186,6 +186,28 @@ describe('ReaderSurface', () => {
186
186
  });
187
187
  });
188
188
 
189
+ describe('ContentRail', () => {
190
+ test('types extend ZoraBaseProps instead of duplicating InteractionPolicy', () => {
191
+ const source = readPattern('content-rail', 'types.ts');
192
+
193
+ expect(source).toMatch(/export interface ContentRailProps extends ZoraBaseProps/);
194
+ expect(source).not.toMatch(/interactionPolicy\?: InteractionPolicy;/);
195
+ });
196
+
197
+ test('suppresses scrolling and forwards interactionPolicy to owned controls', () => {
198
+ const source = readPattern('content-rail', 'ContentRail.tsx');
199
+
200
+ expect(source).not.toMatch(/interactionPolicy:\s*_interactionPolicy/);
201
+ expect(source).toContain('scrollEnabled={!passive}');
202
+ expect(source).toMatch(
203
+ /<IconButton[\s\S]*?interactionPolicy=\{interactionPolicy\}[\s\S]*?label=\{previousLabel\}/,
204
+ );
205
+ expect(source).toMatch(
206
+ /<IconButton[\s\S]*?interactionPolicy=\{interactionPolicy\}[\s\S]*?label=\{nextLabel\}/,
207
+ );
208
+ });
209
+ });
210
+
189
211
  describe('CameraPermissionView', () => {
190
212
  test('forwards interactionPolicy to internal Buttons after prop spreads', () => {
191
213
  const source = readPattern('scanner', 'CameraPermissionView.tsx');
@@ -23,6 +23,21 @@ export const CONTAINER_ALLOWED_CHILDREN = [
23
23
  'MessageBubble',
24
24
  'ProgressRing',
25
25
  'ReaderSurface',
26
+ 'ContentRail',
27
+ 'MissingElement',
28
+ ] as const;
29
+
30
+ export const CONTENT_RAIL_ALLOWED_CHILDREN = [
31
+ 'Box',
32
+ 'Stack',
33
+ 'Card',
34
+ 'Panel',
35
+ 'Image',
36
+ 'ProductCard',
37
+ 'PostCard',
38
+ 'ChatListItem',
39
+ 'MessageBubble',
40
+ 'MissingElement',
26
41
  ] as const;
27
42
 
28
43
  export const SCREEN_SECTION_ALLOWED_CHILDREN = [...CONTAINER_ALLOWED_CHILDREN] as const;
@@ -140,6 +140,7 @@ describe('ZORA_COMPONENT_META invariants', () => {
140
140
  'Progress',
141
141
  'ProgressRing',
142
142
  'ReaderSurface',
143
+ 'MissingElement',
143
144
  ]);
144
145
 
145
146
  const expectedContainerNodes = new Set([
@@ -158,6 +159,7 @@ describe('ZORA_COMPONENT_META invariants', () => {
158
159
  'Grid',
159
160
  'Container',
160
161
  'BarcodeScannerView',
162
+ 'ContentRail',
161
163
  ]);
162
164
 
163
165
  for (const [key, meta] of Object.entries(ZORA_COMPONENT_META)) {
@@ -257,6 +259,54 @@ describe('ZORA_COMPONENT_META invariants', () => {
257
259
  expect(reader.events?.readerError?.eventType).toBe('reader.error');
258
260
  });
259
261
 
262
+ test('ContentRail exposes manifest children, serializable controls, and normalized events', () => {
263
+ const rail = ZORA_COMPONENT_META.ContentRail;
264
+
265
+ expect(rail.directManifestNode).toBe(true);
266
+ expect(rail.allowedChildren).toEqual([
267
+ 'Box',
268
+ 'Stack',
269
+ 'Card',
270
+ 'Panel',
271
+ 'Image',
272
+ 'ProductCard',
273
+ 'PostCard',
274
+ 'ChatListItem',
275
+ 'MessageBubble',
276
+ 'MissingElement',
277
+ ]);
278
+ expect(rail.slots?.children?.allowedChildren).toEqual(rail.allowedChildren);
279
+ expect(rail.blueprint?.defaultProps).toEqual({
280
+ itemSize: 'responsive',
281
+ gap: 'm',
282
+ padding: 'm',
283
+ peek: 32,
284
+ showControls: true,
285
+ direction: 'auto',
286
+ motion: 'system',
287
+ accessibilityLabel: 'Content rail',
288
+ previousLabel: 'Previous items',
289
+ nextLabel: 'Next items',
290
+ });
291
+ expect(rail.events?.controlPress?.eventType).toBe('contentRail.controlPress');
292
+ expect(rail.events?.visibleRangeChange?.eventType).toBe('contentRail.visibleRangeChange');
293
+ expect(rail.requirements).toBeUndefined();
294
+ });
295
+
296
+ test('MissingElement exposes a release-blocking unresolved manifest policy', () => {
297
+ const missingElement = ZORA_COMPONENT_META.MissingElement;
298
+
299
+ expect(missingElement.directManifestNode).toBe(true);
300
+ expect(missingElement.allowedChildren).toEqual([]);
301
+ expect(missingElement.manifestPolicy).toEqual({
302
+ kind: 'unresolved-element',
303
+ availability: 'draft-only',
304
+ releaseGate: 'blocked',
305
+ });
306
+ expect(missingElement.events).toBeUndefined();
307
+ expect(missingElement.requirements).toBeUndefined();
308
+ });
309
+
260
310
  test('non-direct manifest nodes include an explicit note', () => {
261
311
  for (const [key, meta] of Object.entries(ZORA_COMPONENT_META)) {
262
312
  if (meta.directManifestNode) continue;
@@ -63,6 +63,7 @@ import {
63
63
  import { chatListItemMeta } from '../patterns/chat-list-item/meta';
64
64
  import { collectionEditorMeta } from '../patterns/collection-editor/meta';
65
65
  import { confirmDialogMeta } from '../patterns/confirm-dialog/meta';
66
+ import { contentRailMeta } from '../patterns/content-rail/meta';
66
67
  import { disclosureSectionMeta } from '../patterns/disclosure-section/meta';
67
68
  import { emptyStateMeta } from '../patterns/empty-state/meta';
68
69
  import { filterBarMeta } from '../patterns/filter-bar/meta';
@@ -72,6 +73,7 @@ import { imageUploadFieldMeta } from '../patterns/image-upload-field/meta';
72
73
  import { inspectorFieldMeta } from '../patterns/inspector-field/meta';
73
74
  import { listMeta, listRowMeta, listSectionMeta } from '../patterns/list/meta';
74
75
  import { messageBubbleMeta } from '../patterns/message-bubble/meta';
76
+ import { missingElementMeta } from '../patterns/missing-element/meta';
75
77
  import { noticeMeta } from '../patterns/notice/meta';
76
78
  import { panelMeta } from '../patterns/panel/meta';
77
79
  import { postCardMeta } from '../patterns/post-card/meta';
@@ -169,6 +171,7 @@ export const ZORA_COMPONENT_META: ZoraComponentMetaRegistry = {
169
171
  ChatListItem: chatListItemMeta,
170
172
  CollectionEditor: collectionEditorMeta,
171
173
  ConfirmDialog: confirmDialogMeta,
174
+ ContentRail: contentRailMeta,
172
175
  DisclosureSection: disclosureSectionMeta,
173
176
  EmptyState: emptyStateMeta,
174
177
  FilterBar: filterBarMeta,
@@ -180,6 +183,7 @@ export const ZORA_COMPONENT_META: ZoraComponentMetaRegistry = {
180
183
  ListRow: listRowMeta,
181
184
  ListSection: listSectionMeta,
182
185
  MessageBubble: messageBubbleMeta,
186
+ MissingElement: missingElementMeta,
183
187
  Notice: noticeMeta,
184
188
  Panel: panelMeta,
185
189
  PostCard: postCardMeta,
@@ -21,6 +21,7 @@ export type {
21
21
  ZoraComponentEventPayloadFieldType,
22
22
  ZoraComponentEventPayloadKind,
23
23
  ZoraComponentI18nMeta,
24
+ ZoraComponentManifestPolicy,
24
25
  ZoraComponentMeta,
25
26
  ZoraComponentMetaRegistry,
26
27
  ZoraComponentPropArrayItemSchema,
@@ -74,12 +74,19 @@ export interface ZoraComponentSlotMeta {
74
74
  allowedChildren?: readonly string[];
75
75
  }
76
76
 
77
+ export interface ZoraComponentManifestPolicy {
78
+ kind: 'unresolved-element';
79
+ availability: 'draft-only';
80
+ releaseGate: 'blocked';
81
+ }
82
+
77
83
  export interface ZoraComponentMeta {
78
84
  name: string;
79
85
  category: ZoraComponentCategory;
80
86
  description?: string;
81
87
  directManifestNode: boolean;
82
88
  allowedChildren: readonly string[];
89
+ manifestPolicy?: ZoraComponentManifestPolicy;
83
90
  requirements?: ComponentRequirements;
84
91
  blueprint?: ZoraComponentBlueprint;
85
92
  events?: Readonly<Record<string, ZoraComponentEventMeta>>;
@@ -0,0 +1,44 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import type {
4
+ ContentRailControlPressEvent,
5
+ ContentRailProps,
6
+ ContentRailVisibleRangeChangeEvent,
7
+ } from '../../index';
8
+
9
+ describe('ContentRail public contract', () => {
10
+ test('exports serializable manifest and event inputs from the package root', () => {
11
+ const props: ContentRailProps = {
12
+ direction: 'rtl',
13
+ gap: 'm',
14
+ itemSize: 'responsive',
15
+ motion: 'reduced',
16
+ padding: 'm',
17
+ peek: 32,
18
+ showControls: true,
19
+ };
20
+ const control: ContentRailControlPressEvent = { direction: 'next', targetIndex: 2 };
21
+ const range: ContentRailVisibleRangeChangeEvent = {
22
+ firstVisibleIndex: 1,
23
+ lastVisibleIndex: 3,
24
+ itemCount: 6,
25
+ };
26
+
27
+ expect(() => JSON.stringify(props)).not.toThrow();
28
+ expect(() => JSON.stringify(control)).not.toThrow();
29
+ expect(() => JSON.stringify(range)).not.toThrow();
30
+ });
31
+
32
+ test('keeps product data, navigation, and platform coupling outside the pattern', async () => {
33
+ const source = await Bun.file('src/patterns/content-rail/ContentRail.tsx').text();
34
+
35
+ expect(source).toContain('ScrollView');
36
+ expect(source).toContain('I18nManager.isRTL');
37
+ expect(source).toContain('accessibilityRole="list"');
38
+ expect(source).toContain('reducedMotion');
39
+ expect(source).toContain('accessibilityLiveRegion="polite"');
40
+ expect(source).not.toContain('expo-');
41
+ expect(source).not.toContain('fetch(');
42
+ expect(source).not.toContain('router');
43
+ });
44
+ });
@@ -0,0 +1,245 @@
1
+ import React from 'react';
2
+ import {
3
+ I18nManager,
4
+ type NativeScrollEvent,
5
+ type NativeSyntheticEvent,
6
+ ScrollView,
7
+ type ViewStyle,
8
+ } from 'react-native';
9
+
10
+ import { IconButton } from '../../components/icon-button';
11
+ import { Text } from '../../components/text';
12
+ import { Box, Inline, Stack } from '../../foundation';
13
+ import { useZoraTheme } from '../../theme/useZoraTheme';
14
+ import { withZoraThemeScope } from '../../theme/withZoraThemeScope';
15
+ import { resolveContentRailItemWidth } from './resolveContentRailItemWidth';
16
+ import { resolveContentRailPhysicalOffset } from './resolveContentRailPhysicalOffset';
17
+ import { resolveContentRailState } from './resolveContentRailState';
18
+ import type { ContentRailProps, ContentRailVisibleRangeChangeEvent } from './types';
19
+ import { useContentRailReducedMotion } from './useContentRailReducedMotion';
20
+
21
+ function ContentRailInner({
22
+ themeId: _themeId,
23
+ mode: _mode,
24
+ interactionPolicy,
25
+ children,
26
+ itemSize = 'responsive',
27
+ gap = 'm',
28
+ padding = 'm',
29
+ peek = 32,
30
+ showControls = true,
31
+ direction = 'auto',
32
+ motion = 'system',
33
+ accessibilityLabel = 'Content rail',
34
+ previousLabel = 'Previous items',
35
+ nextLabel = 'Next items',
36
+ onControlPress,
37
+ onVisibleRangeChange,
38
+ testID,
39
+ }: ContentRailProps) {
40
+ const { theme } = useZoraTheme();
41
+ const items = React.Children.toArray(children);
42
+ const scrollViewRef = React.useRef<ScrollView>(null);
43
+ const lastRangeRef = React.useRef<ContentRailVisibleRangeChangeEvent | null>(null);
44
+ const logicalOffsetRef = React.useRef(0);
45
+ const rtlInitializedRef = React.useRef(false);
46
+ const [viewportWidth, setViewportWidth] = React.useState(0);
47
+ const [contentWidth, setContentWidth] = React.useState(0);
48
+ const [logicalOffset, setLogicalOffset] = React.useState(0);
49
+ const reducedMotion = useContentRailReducedMotion(motion);
50
+ const isRtl = direction === 'rtl' || (direction === 'auto' && I18nManager.isRTL);
51
+ const gapValue = resolveSpacingValue(theme.spacing, gap);
52
+ const paddingValue = resolveSpacingValue(theme.spacing, padding);
53
+ const itemWidth = resolveContentRailItemWidth({
54
+ gap: gapValue,
55
+ itemCount: items.length,
56
+ itemSize,
57
+ padding: paddingValue,
58
+ peek,
59
+ viewportWidth,
60
+ });
61
+ const railState = resolveContentRailState({
62
+ contentWidth,
63
+ gap: gapValue,
64
+ itemCount: items.length,
65
+ itemWidth,
66
+ logicalOffset,
67
+ viewportWidth,
68
+ });
69
+ const passive = interactionPolicy === 'passive';
70
+ const hasOverflow = railState.maxOffset > 1;
71
+
72
+ const emitVisibleRange = React.useCallback(
73
+ (nextOffset: number) => {
74
+ const next = resolveContentRailState({
75
+ contentWidth,
76
+ gap: gapValue,
77
+ itemCount: items.length,
78
+ itemWidth,
79
+ logicalOffset: nextOffset,
80
+ viewportWidth,
81
+ });
82
+ const event = {
83
+ firstVisibleIndex: next.firstVisibleIndex,
84
+ lastVisibleIndex: next.lastVisibleIndex,
85
+ itemCount: items.length,
86
+ };
87
+ const previous = lastRangeRef.current;
88
+ if (
89
+ previous?.firstVisibleIndex === event.firstVisibleIndex &&
90
+ previous.lastVisibleIndex === event.lastVisibleIndex &&
91
+ previous.itemCount === event.itemCount
92
+ ) {
93
+ return;
94
+ }
95
+ lastRangeRef.current = event;
96
+ void onVisibleRangeChange?.(event);
97
+ },
98
+ [contentWidth, gapValue, itemWidth, items.length, onVisibleRangeChange, viewportWidth],
99
+ );
100
+
101
+ React.useEffect(() => {
102
+ emitVisibleRange(railState.offset);
103
+ }, [emitVisibleRange, railState.offset]);
104
+
105
+ React.useEffect(() => {
106
+ rtlInitializedRef.current = !isRtl;
107
+ }, [isRtl]);
108
+
109
+ React.useEffect(() => {
110
+ if (viewportWidth === 0 || contentWidth === 0) return;
111
+ const nextOffset = Math.min(logicalOffsetRef.current, railState.maxOffset);
112
+ scrollViewRef.current?.scrollTo({
113
+ animated: false,
114
+ x: resolveContentRailPhysicalOffset({
115
+ isRtl,
116
+ logicalOffset: nextOffset,
117
+ maxOffset: railState.maxOffset,
118
+ }),
119
+ y: 0,
120
+ });
121
+ rtlInitializedRef.current = true;
122
+ }, [contentWidth, isRtl, itemWidth, railState.maxOffset, viewportWidth]);
123
+
124
+ const scrollToIndex = (targetIndex: number, controlDirection: 'previous' | 'next') => {
125
+ if (passive || !hasOverflow) return;
126
+ const clampedIndex = Math.min(Math.max(0, targetIndex), Math.max(0, items.length - 1));
127
+ const targetOffset = Math.min(clampedIndex * railState.stride, railState.maxOffset);
128
+ logicalOffsetRef.current = targetOffset;
129
+ setLogicalOffset(targetOffset);
130
+ scrollViewRef.current?.scrollTo({
131
+ animated: !reducedMotion,
132
+ x: resolveContentRailPhysicalOffset({
133
+ isRtl,
134
+ logicalOffset: targetOffset,
135
+ maxOffset: railState.maxOffset,
136
+ }),
137
+ y: 0,
138
+ });
139
+ emitVisibleRange(targetOffset);
140
+ void onControlPress?.({ direction: controlDirection, targetIndex: clampedIndex });
141
+ };
142
+
143
+ const handleScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
144
+ if (isRtl && !rtlInitializedRef.current) return;
145
+ const physicalOffset = event.nativeEvent.contentOffset.x;
146
+ const nextOffset = resolveContentRailPhysicalOffset({
147
+ isRtl,
148
+ logicalOffset: physicalOffset,
149
+ maxOffset: railState.maxOffset,
150
+ });
151
+ const clampedOffset = Math.min(Math.max(0, nextOffset), railState.maxOffset);
152
+ logicalOffsetRef.current = clampedOffset;
153
+ setLogicalOffset(clampedOffset);
154
+ emitVisibleRange(clampedOffset);
155
+ };
156
+
157
+ const rangeLabel =
158
+ items.length === 0
159
+ ? 'No items'
160
+ : `Items ${railState.firstVisibleIndex + 1}–${railState.lastVisibleIndex + 1} of ${items.length}`;
161
+ const contentStyle: ViewStyle = {
162
+ direction: isRtl ? 'rtl' : 'ltr',
163
+ flexDirection: 'row',
164
+ gap: gapValue,
165
+ paddingHorizontal: paddingValue,
166
+ };
167
+
168
+ return (
169
+ <Stack gap="s" testID={testID}>
170
+ {showControls && hasOverflow ? (
171
+ <Inline justify="space-between" wrap="nowrap">
172
+ <Text accessibilityLiveRegion="polite" emphasis="muted" variant="caption">
173
+ {rangeLabel}
174
+ </Text>
175
+ <Inline gap="xs" wrap="nowrap">
176
+ <IconButton
177
+ disabled={passive || !railState.canGoPrevious}
178
+ icon={{ name: isRtl ? 'chevron-forward' : 'chevron-back' }}
179
+ interactionPolicy={interactionPolicy}
180
+ label={previousLabel}
181
+ onPress={() => scrollToIndex(railState.anchorIndex - 1, 'previous')}
182
+ size="l"
183
+ />
184
+ <IconButton
185
+ disabled={passive || !railState.canGoNext}
186
+ icon={{ name: isRtl ? 'chevron-back' : 'chevron-forward' }}
187
+ interactionPolicy={interactionPolicy}
188
+ label={nextLabel}
189
+ onPress={() => scrollToIndex(railState.anchorIndex + 1, 'next')}
190
+ size="l"
191
+ />
192
+ </Inline>
193
+ </Inline>
194
+ ) : null}
195
+ <ScrollView
196
+ accessibilityLabel={accessibilityLabel}
197
+ accessibilityRole="list"
198
+ contentContainerStyle={contentStyle}
199
+ decelerationRate="fast"
200
+ disableIntervalMomentum
201
+ horizontal
202
+ onContentSizeChange={(width) => {
203
+ if (isRtl) rtlInitializedRef.current = false;
204
+ setContentWidth(width);
205
+ }}
206
+ onLayout={(event) => {
207
+ if (isRtl) rtlInitializedRef.current = false;
208
+ setViewportWidth(event.nativeEvent.layout.width);
209
+ }}
210
+ onScroll={handleScroll}
211
+ ref={scrollViewRef}
212
+ scrollEnabled={!passive}
213
+ scrollEventThrottle={16}
214
+ showsHorizontalScrollIndicator={false}
215
+ snapToInterval={itemWidth > 0 ? itemWidth + gapValue : undefined}
216
+ >
217
+ {items.map((item, index) => (
218
+ <Box key={index} style={{ flexShrink: 0, width: itemWidth || undefined }}>
219
+ {item}
220
+ </Box>
221
+ ))}
222
+ </ScrollView>
223
+ </Stack>
224
+ );
225
+ }
226
+
227
+ /**
228
+ * Horizontally browsable, manifest-authorable content with responsive item sizing.
229
+ *
230
+ * ContentRail owns only presentation and scrolling. Apps retain ownership of item data,
231
+ * navigation, recommendations, and route behavior.
232
+ */
233
+ export const ContentRail = withZoraThemeScope(ContentRailInner);
234
+
235
+ function resolveSpacingValue(
236
+ spacing: { none: number; xs: number; s: number; m: number; l: number; xl: number },
237
+ token: NonNullable<ContentRailProps['gap']>,
238
+ ): number {
239
+ if (token === 'none') return spacing.none;
240
+ if (token === 'xs') return spacing.xs;
241
+ if (token === 's') return spacing.s;
242
+ if (token === 'l') return spacing.l;
243
+ if (token === 'xl') return spacing.xl;
244
+ return spacing.m;
245
+ }
@@ -0,0 +1,10 @@
1
+ export { ContentRail } from './ContentRail';
2
+ export type {
3
+ ContentRailControlPressEvent,
4
+ ContentRailDirection,
5
+ ContentRailItemSize,
6
+ ContentRailMotion,
7
+ ContentRailProps,
8
+ ContentRailSpacing,
9
+ ContentRailVisibleRangeChangeEvent,
10
+ } from './types';
@@ -0,0 +1,133 @@
1
+ import type { ZoraComponentMeta } from '../../metadata';
2
+ import { CONTENT_RAIL_ALLOWED_CHILDREN } from '../../metadata/allowedChildren';
3
+
4
+ const spacingValues = ['none', 'xs', 's', 'm', 'l', 'xl'] as const;
5
+
6
+ export const contentRailMeta = {
7
+ name: 'ContentRail',
8
+ category: 'pattern',
9
+ description:
10
+ 'Horizontally browsable child content with responsive sizing, partial-next affordance, and accessible controls.',
11
+ directManifestNode: true,
12
+ allowedChildren: [...CONTENT_RAIL_ALLOWED_CHILDREN],
13
+ blueprint: {
14
+ label: 'Content rail',
15
+ icon: { name: 'albums-outline' },
16
+ defaultProps: {
17
+ itemSize: 'responsive',
18
+ gap: 'm',
19
+ padding: 'm',
20
+ peek: 32,
21
+ showControls: true,
22
+ direction: 'auto',
23
+ motion: 'system',
24
+ accessibilityLabel: 'Content rail',
25
+ previousLabel: 'Previous items',
26
+ nextLabel: 'Next items',
27
+ },
28
+ },
29
+ events: {
30
+ controlPress: {
31
+ label: 'Control press',
32
+ eventType: 'contentRail.controlPress',
33
+ description: 'Emitted after a previous or next control requests a target item.',
34
+ payloadFields: [
35
+ { path: 'direction', type: 'string', label: 'Direction' },
36
+ { path: 'targetIndex', type: 'number', label: 'Target index' },
37
+ ],
38
+ },
39
+ visibleRangeChange: {
40
+ label: 'Visible range change',
41
+ eventType: 'contentRail.visibleRangeChange',
42
+ description: 'Emitted when the zero-based visible item range changes.',
43
+ payloadFields: [
44
+ { path: 'firstVisibleIndex', type: 'number', label: 'First visible index' },
45
+ { path: 'lastVisibleIndex', type: 'number', label: 'Last visible index' },
46
+ { path: 'itemCount', type: 'number', label: 'Item count' },
47
+ ],
48
+ },
49
+ },
50
+ slots: {
51
+ children: {
52
+ label: 'Rail items',
53
+ allowedChildren: [...CONTENT_RAIL_ALLOWED_CHILDREN],
54
+ },
55
+ },
56
+ props: {
57
+ itemSize: {
58
+ type: 'enum',
59
+ category: 'Layout',
60
+ label: 'Item size',
61
+ enum: ['compact', 'regular', 'wide', 'responsive'],
62
+ default: 'responsive',
63
+ },
64
+ gap: {
65
+ type: 'enum',
66
+ category: 'Layout',
67
+ label: 'Gap',
68
+ enum: spacingValues,
69
+ default: 'm',
70
+ },
71
+ padding: {
72
+ type: 'enum',
73
+ category: 'Layout',
74
+ label: 'Horizontal padding',
75
+ enum: spacingValues,
76
+ default: 'm',
77
+ },
78
+ peek: {
79
+ type: 'number',
80
+ category: 'Layout',
81
+ label: 'Partial next item',
82
+ default: 32,
83
+ },
84
+ showControls: {
85
+ type: 'boolean',
86
+ category: 'Controls',
87
+ label: 'Show controls',
88
+ default: true,
89
+ },
90
+ direction: {
91
+ type: 'enum',
92
+ category: 'Layout',
93
+ label: 'Reading direction',
94
+ enum: ['auto', 'ltr', 'rtl'],
95
+ default: 'auto',
96
+ },
97
+ motion: {
98
+ type: 'enum',
99
+ category: 'Accessibility',
100
+ label: 'Motion',
101
+ enum: ['system', 'animated', 'reduced'],
102
+ default: 'system',
103
+ },
104
+ accessibilityLabel: {
105
+ type: 'string',
106
+ category: 'Accessibility',
107
+ label: 'Rail label',
108
+ default: 'Content rail',
109
+ },
110
+ previousLabel: {
111
+ type: 'string',
112
+ category: 'Accessibility',
113
+ label: 'Previous control label',
114
+ default: 'Previous items',
115
+ },
116
+ nextLabel: {
117
+ type: 'string',
118
+ category: 'Accessibility',
119
+ label: 'Next control label',
120
+ default: 'Next items',
121
+ },
122
+ onControlPress: {
123
+ type: 'action',
124
+ category: 'Events',
125
+ label: 'Control press action',
126
+ },
127
+ onVisibleRangeChange: {
128
+ type: 'action',
129
+ category: 'Events',
130
+ label: 'Visible range change action',
131
+ },
132
+ },
133
+ } as const satisfies ZoraComponentMeta;