@ankhorage/zora 3.1.1 → 3.3.1

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 (105) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +1 -1
  3. package/dist/components/card/meta.d.ts +1 -1
  4. package/dist/components/progress/ProgressRing.d.ts +7 -0
  5. package/dist/components/progress/ProgressRing.d.ts.map +1 -0
  6. package/dist/components/progress/ProgressRing.js +100 -0
  7. package/dist/components/progress/ProgressRing.js.map +1 -0
  8. package/dist/components/progress/index.d.ts +2 -1
  9. package/dist/components/progress/index.d.ts.map +1 -1
  10. package/dist/components/progress/index.js +1 -0
  11. package/dist/components/progress/index.js.map +1 -1
  12. package/dist/components/progress/meta.d.ts +116 -0
  13. package/dist/components/progress/meta.d.ts.map +1 -1
  14. package/dist/components/progress/meta.js +97 -0
  15. package/dist/components/progress/meta.js.map +1 -1
  16. package/dist/components/progress/resolveProgressRingGeometry.d.ts +12 -0
  17. package/dist/components/progress/resolveProgressRingGeometry.d.ts.map +1 -0
  18. package/dist/components/progress/resolveProgressRingGeometry.js +27 -0
  19. package/dist/components/progress/resolveProgressRingGeometry.js.map +1 -0
  20. package/dist/components/progress/types.d.ts +12 -0
  21. package/dist/components/progress/types.d.ts.map +1 -1
  22. package/dist/components/progress/types.js.map +1 -1
  23. package/dist/foundation/meta.d.ts +4 -4
  24. package/dist/index.d.ts +4 -2
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +2 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/layout/screen/meta.d.ts +1 -1
  29. package/dist/layout/screen-section/meta.d.ts +1 -1
  30. package/dist/metadata/allowedChildren.d.ts +4 -3
  31. package/dist/metadata/allowedChildren.d.ts.map +1 -1
  32. package/dist/metadata/allowedChildren.js +13 -0
  33. package/dist/metadata/allowedChildren.js.map +1 -1
  34. package/dist/metadata/bindableComponentMeta.d.ts +2 -2
  35. package/dist/metadata/componentMeta.d.ts.map +1 -1
  36. package/dist/metadata/componentMeta.js +4 -1
  37. package/dist/metadata/componentMeta.js.map +1 -1
  38. package/dist/patterns/content-rail/ContentRail.d.ts +10 -0
  39. package/dist/patterns/content-rail/ContentRail.d.ts.map +1 -0
  40. package/dist/patterns/content-rail/ContentRail.js +175 -0
  41. package/dist/patterns/content-rail/ContentRail.js.map +1 -0
  42. package/dist/patterns/content-rail/index.d.ts +3 -0
  43. package/dist/patterns/content-rail/index.d.ts.map +1 -0
  44. package/dist/patterns/content-rail/index.js +2 -0
  45. package/dist/patterns/content-rail/index.js.map +1 -0
  46. package/dist/patterns/content-rail/meta.d.ts +143 -0
  47. package/dist/patterns/content-rail/meta.d.ts.map +1 -0
  48. package/dist/patterns/content-rail/meta.js +130 -0
  49. package/dist/patterns/content-rail/meta.js.map +1 -0
  50. package/dist/patterns/content-rail/resolveContentRailItemWidth.d.ts +10 -0
  51. package/dist/patterns/content-rail/resolveContentRailItemWidth.d.ts.map +1 -0
  52. package/dist/patterns/content-rail/resolveContentRailItemWidth.js +25 -0
  53. package/dist/patterns/content-rail/resolveContentRailItemWidth.js.map +1 -0
  54. package/dist/patterns/content-rail/resolveContentRailPhysicalOffset.d.ts +6 -0
  55. package/dist/patterns/content-rail/resolveContentRailPhysicalOffset.d.ts.map +1 -0
  56. package/dist/patterns/content-rail/resolveContentRailPhysicalOffset.js +5 -0
  57. package/dist/patterns/content-rail/resolveContentRailPhysicalOffset.js.map +1 -0
  58. package/dist/patterns/content-rail/resolveContentRailState.d.ts +18 -0
  59. package/dist/patterns/content-rail/resolveContentRailState.d.ts.map +1 -0
  60. package/dist/patterns/content-rail/resolveContentRailState.js +21 -0
  61. package/dist/patterns/content-rail/resolveContentRailState.js.map +1 -0
  62. package/dist/patterns/content-rail/types.d.ts +31 -0
  63. package/dist/patterns/content-rail/types.d.ts.map +1 -0
  64. package/dist/patterns/content-rail/types.js +2 -0
  65. package/dist/patterns/content-rail/types.js.map +1 -0
  66. package/dist/patterns/content-rail/useContentRailReducedMotion.d.ts +3 -0
  67. package/dist/patterns/content-rail/useContentRailReducedMotion.d.ts.map +1 -0
  68. package/dist/patterns/content-rail/useContentRailReducedMotion.js +21 -0
  69. package/dist/patterns/content-rail/useContentRailReducedMotion.js.map +1 -0
  70. package/dist/patterns/message-bubble/meta.d.ts +1 -1
  71. package/dist/patterns/notice/meta.d.ts +1 -1
  72. package/dist/patterns/panel/meta.d.ts +1 -1
  73. package/dist/patterns/post-card/meta.d.ts +1 -1
  74. package/dist/patterns/reader/meta.d.ts +2 -2
  75. package/dist/patterns/scanner/meta.d.ts +2 -2
  76. package/dist/registry.d.ts.map +1 -1
  77. package/dist/registry.js +4 -1
  78. package/dist/registry.js.map +1 -1
  79. package/package.json +2 -2
  80. package/src/components/progress/ProgressRing.test.ts +48 -0
  81. package/src/components/progress/ProgressRing.tsx +151 -0
  82. package/src/components/progress/index.ts +2 -1
  83. package/src/components/progress/meta.ts +99 -0
  84. package/src/components/progress/resolveProgressRingGeometry.test.ts +30 -0
  85. package/src/components/progress/resolveProgressRingGeometry.ts +44 -0
  86. package/src/components/progress/types.ts +13 -0
  87. package/src/index.ts +12 -2
  88. package/src/interactionPolicy.test.ts +22 -0
  89. package/src/metadata/allowedChildren.ts +14 -0
  90. package/src/metadata/componentMeta.test.ts +51 -0
  91. package/src/metadata/componentMeta.ts +4 -1
  92. package/src/patterns/content-rail/ContentRail.test.ts +44 -0
  93. package/src/patterns/content-rail/ContentRail.tsx +245 -0
  94. package/src/patterns/content-rail/index.ts +10 -0
  95. package/src/patterns/content-rail/meta.ts +133 -0
  96. package/src/patterns/content-rail/resolveContentRailItemWidth.test.ts +77 -0
  97. package/src/patterns/content-rail/resolveContentRailItemWidth.ts +39 -0
  98. package/src/patterns/content-rail/resolveContentRailPhysicalOffset.test.ts +29 -0
  99. package/src/patterns/content-rail/resolveContentRailPhysicalOffset.ts +12 -0
  100. package/src/patterns/content-rail/resolveContentRailState.test.ts +68 -0
  101. package/src/patterns/content-rail/resolveContentRailState.ts +36 -0
  102. package/src/patterns/content-rail/types.ts +35 -0
  103. package/src/patterns/content-rail/useContentRailReducedMotion.ts +28 -0
  104. package/src/registry.ts +4 -1
  105. package/src/showcaseCoverage.test.ts +1 -0
@@ -138,6 +138,7 @@ describe('ZORA_COMPONENT_META invariants', () => {
138
138
  'ScanOverlay',
139
139
  'ProductCard',
140
140
  'Progress',
141
+ 'ProgressRing',
141
142
  'ReaderSurface',
142
143
  ]);
143
144
 
@@ -157,6 +158,7 @@ describe('ZORA_COMPONENT_META invariants', () => {
157
158
  'Grid',
158
159
  'Container',
159
160
  'BarcodeScannerView',
161
+ 'ContentRail',
160
162
  ]);
161
163
 
162
164
  for (const [key, meta] of Object.entries(ZORA_COMPONENT_META)) {
@@ -211,6 +213,22 @@ describe('ZORA_COMPONENT_META invariants', () => {
211
213
  });
212
214
  });
213
215
 
216
+ test('ProgressRing is a direct manifest leaf with serializable center content', () => {
217
+ const progressRing = ZORA_COMPONENT_META.ProgressRing;
218
+
219
+ expect(progressRing.directManifestNode).toBe(true);
220
+ expect(progressRing.allowedChildren).toEqual([]);
221
+ expect(progressRing.blueprint?.defaultProps).toMatchObject({
222
+ centerLabel: 'Complete',
223
+ centerValue: '68%',
224
+ max: 100,
225
+ value: 68,
226
+ });
227
+ expect(progressRing.props.centerValue?.type).toBe('string');
228
+ expect(progressRing.props.centerLabel?.type).toBe('string');
229
+ expect(progressRing.props.accessibilityValueText?.type).toBe('string');
230
+ });
231
+
214
232
  test('ReaderSurface exposes a safe file-media blueprint and normalized events', () => {
215
233
  const reader = ZORA_COMPONENT_META.ReaderSurface;
216
234
 
@@ -240,6 +258,39 @@ describe('ZORA_COMPONENT_META invariants', () => {
240
258
  expect(reader.events?.readerError?.eventType).toBe('reader.error');
241
259
  });
242
260
 
261
+ test('ContentRail exposes manifest children, serializable controls, and normalized events', () => {
262
+ const rail = ZORA_COMPONENT_META.ContentRail;
263
+
264
+ expect(rail.directManifestNode).toBe(true);
265
+ expect(rail.allowedChildren).toEqual([
266
+ 'Box',
267
+ 'Stack',
268
+ 'Card',
269
+ 'Panel',
270
+ 'Image',
271
+ 'ProductCard',
272
+ 'PostCard',
273
+ 'ChatListItem',
274
+ 'MessageBubble',
275
+ ]);
276
+ expect(rail.slots?.children?.allowedChildren).toEqual(rail.allowedChildren);
277
+ expect(rail.blueprint?.defaultProps).toEqual({
278
+ itemSize: 'responsive',
279
+ gap: 'm',
280
+ padding: 'm',
281
+ peek: 32,
282
+ showControls: true,
283
+ direction: 'auto',
284
+ motion: 'system',
285
+ accessibilityLabel: 'Content rail',
286
+ previousLabel: 'Previous items',
287
+ nextLabel: 'Next items',
288
+ });
289
+ expect(rail.events?.controlPress?.eventType).toBe('contentRail.controlPress');
290
+ expect(rail.events?.visibleRangeChange?.eventType).toBe('contentRail.visibleRangeChange');
291
+ expect(rail.requirements).toBeUndefined();
292
+ });
293
+
243
294
  test('non-direct manifest nodes include an explicit note', () => {
244
295
  for (const [key, meta] of Object.entries(ZORA_COMPONENT_META)) {
245
296
  if (meta.directManifestNode) continue;
@@ -27,7 +27,7 @@ import { modalMeta } from '../components/modal/meta';
27
27
  import { navigationItemMeta } from '../components/navigation-item/meta';
28
28
  import { navigationListMeta } from '../components/navigation-list/meta';
29
29
  import { paginationMeta } from '../components/pagination/meta';
30
- import { progressMeta } from '../components/progress/meta';
30
+ import { progressMeta, progressRingMeta } from '../components/progress/meta';
31
31
  import { radioGroupMeta, radioMeta } from '../components/radio/meta';
32
32
  import { ratingMeta } from '../components/rating/meta';
33
33
  import { searchBarMeta } from '../components/search-bar/meta';
@@ -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';
@@ -134,6 +135,7 @@ export const ZORA_COMPONENT_META: ZoraComponentMetaRegistry = {
134
135
  NavigationList: navigationListMeta,
135
136
  Pagination: paginationMeta,
136
137
  Progress: progressMeta,
138
+ ProgressRing: progressRingMeta,
137
139
  Radio: radioMeta,
138
140
  RadioGroup: radioGroupMeta,
139
141
  Rating: ratingMeta,
@@ -168,6 +170,7 @@ export const ZORA_COMPONENT_META: ZoraComponentMetaRegistry = {
168
170
  ChatListItem: chatListItemMeta,
169
171
  CollectionEditor: collectionEditorMeta,
170
172
  ConfirmDialog: confirmDialogMeta,
173
+ ContentRail: contentRailMeta,
171
174
  DisclosureSection: disclosureSectionMeta,
172
175
  EmptyState: emptyStateMeta,
173
176
  FilterBar: filterBarMeta,
@@ -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;
@@ -0,0 +1,77 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { resolveContentRailItemWidth } from './resolveContentRailItemWidth';
4
+
5
+ describe('resolveContentRailItemWidth', () => {
6
+ test('keeps fixed item sizes within the available viewport', () => {
7
+ expect(
8
+ resolveContentRailItemWidth({
9
+ gap: 16,
10
+ itemCount: 4,
11
+ itemSize: 'regular',
12
+ padding: 16,
13
+ peek: 32,
14
+ viewportWidth: 500,
15
+ }),
16
+ ).toBe(200);
17
+ expect(
18
+ resolveContentRailItemWidth({
19
+ gap: 16,
20
+ itemCount: 4,
21
+ itemSize: 'wide',
22
+ padding: 16,
23
+ peek: 32,
24
+ viewportWidth: 240,
25
+ }),
26
+ ).toBe(208);
27
+ });
28
+
29
+ test('reserves a deterministic partial-next affordance at responsive breakpoints', () => {
30
+ expect(
31
+ resolveContentRailItemWidth({
32
+ gap: 16,
33
+ itemCount: 5,
34
+ itemSize: 'responsive',
35
+ padding: 16,
36
+ peek: 32,
37
+ viewportWidth: 500,
38
+ }),
39
+ ).toBe(202);
40
+ expect(
41
+ resolveContentRailItemWidth({
42
+ gap: 16,
43
+ itemCount: 5,
44
+ itemSize: 'responsive',
45
+ padding: 16,
46
+ peek: 32,
47
+ viewportWidth: 800,
48
+ }),
49
+ ).toBe(229);
50
+ });
51
+
52
+ test('removes the peek and distributes items predictably when content fits', () => {
53
+ expect(
54
+ resolveContentRailItemWidth({
55
+ gap: 16,
56
+ itemCount: 2,
57
+ itemSize: 'responsive',
58
+ padding: 16,
59
+ peek: 32,
60
+ viewportWidth: 800,
61
+ }),
62
+ ).toBe(280);
63
+ });
64
+
65
+ test('handles empty and unmeasured rails', () => {
66
+ expect(
67
+ resolveContentRailItemWidth({
68
+ gap: 16,
69
+ itemCount: 0,
70
+ itemSize: 'responsive',
71
+ padding: 16,
72
+ peek: 32,
73
+ viewportWidth: 500,
74
+ }),
75
+ ).toBe(0);
76
+ });
77
+ });
@@ -0,0 +1,39 @@
1
+ import type { ContentRailItemSize } from './types';
2
+
3
+ export function resolveContentRailItemWidth({
4
+ gap,
5
+ itemCount,
6
+ itemSize,
7
+ padding,
8
+ peek,
9
+ viewportWidth,
10
+ }: {
11
+ gap: number;
12
+ itemCount: number;
13
+ itemSize: ContentRailItemSize;
14
+ padding: number;
15
+ peek: number;
16
+ viewportWidth: number;
17
+ }): number {
18
+ const availableWidth = Math.max(0, viewportWidth - padding * 2);
19
+ if (availableWidth === 0 || itemCount === 0) return 0;
20
+
21
+ const fixedWidth = resolveFixedItemWidth(itemSize);
22
+ if (fixedWidth !== undefined) return Math.min(fixedWidth, availableWidth);
23
+
24
+ const slots = viewportWidth >= 1024 ? 4 : viewportWidth >= 768 ? 3 : viewportWidth >= 480 ? 2 : 1;
25
+ const fullItems = Math.min(slots, itemCount);
26
+ const overflows = itemCount > fullItems;
27
+ const resolvedPeek = overflows ? Math.min(Math.max(0, peek), availableWidth / 2) : 0;
28
+ const gaps = overflows ? fullItems : Math.max(0, fullItems - 1);
29
+ const resolved = (availableWidth - resolvedPeek - gap * gaps) / fullItems;
30
+
31
+ return Math.min(280, Math.max(Math.min(96, availableWidth), Math.floor(resolved)));
32
+ }
33
+
34
+ function resolveFixedItemWidth(itemSize: ContentRailItemSize): number | undefined {
35
+ if (itemSize === 'compact') return 144;
36
+ if (itemSize === 'regular') return 200;
37
+ if (itemSize === 'wide') return 280;
38
+ return undefined;
39
+ }
@@ -0,0 +1,29 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { resolveContentRailPhysicalOffset } from './resolveContentRailPhysicalOffset';
4
+
5
+ describe('resolveContentRailPhysicalOffset', () => {
6
+ test('keeps logical and physical offsets aligned for LTR content', () => {
7
+ expect(
8
+ resolveContentRailPhysicalOffset({ isRtl: false, logicalOffset: 216, maxOffset: 648 }),
9
+ ).toBe(216);
10
+ });
11
+
12
+ test('inverts offsets for RTL while preserving logical boundaries', () => {
13
+ expect(
14
+ resolveContentRailPhysicalOffset({ isRtl: true, logicalOffset: 0, maxOffset: 648 }),
15
+ ).toBe(648);
16
+ expect(
17
+ resolveContentRailPhysicalOffset({ isRtl: true, logicalOffset: 216, maxOffset: 648 }),
18
+ ).toBe(432);
19
+ expect(
20
+ resolveContentRailPhysicalOffset({ isRtl: true, logicalOffset: 648, maxOffset: 648 }),
21
+ ).toBe(0);
22
+ });
23
+
24
+ test('clamps offsets rather than wrapping', () => {
25
+ expect(
26
+ resolveContentRailPhysicalOffset({ isRtl: true, logicalOffset: 900, maxOffset: 648 }),
27
+ ).toBe(0);
28
+ });
29
+ });