@ankhorage/zora 3.2.0 → 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 (77) hide show
  1. package/CHANGELOG.md +12 -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 +2 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +1 -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 +12 -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 +2 -0
  17. package/dist/metadata/componentMeta.js.map +1 -1
  18. package/dist/patterns/content-rail/ContentRail.d.ts +10 -0
  19. package/dist/patterns/content-rail/ContentRail.d.ts.map +1 -0
  20. package/dist/patterns/content-rail/ContentRail.js +175 -0
  21. package/dist/patterns/content-rail/ContentRail.js.map +1 -0
  22. package/dist/patterns/content-rail/index.d.ts +3 -0
  23. package/dist/patterns/content-rail/index.d.ts.map +1 -0
  24. package/dist/patterns/content-rail/index.js +2 -0
  25. package/dist/patterns/content-rail/index.js.map +1 -0
  26. package/dist/patterns/content-rail/meta.d.ts +143 -0
  27. package/dist/patterns/content-rail/meta.d.ts.map +1 -0
  28. package/dist/patterns/content-rail/meta.js +130 -0
  29. package/dist/patterns/content-rail/meta.js.map +1 -0
  30. package/dist/patterns/content-rail/resolveContentRailItemWidth.d.ts +10 -0
  31. package/dist/patterns/content-rail/resolveContentRailItemWidth.d.ts.map +1 -0
  32. package/dist/patterns/content-rail/resolveContentRailItemWidth.js +25 -0
  33. package/dist/patterns/content-rail/resolveContentRailItemWidth.js.map +1 -0
  34. package/dist/patterns/content-rail/resolveContentRailPhysicalOffset.d.ts +6 -0
  35. package/dist/patterns/content-rail/resolveContentRailPhysicalOffset.d.ts.map +1 -0
  36. package/dist/patterns/content-rail/resolveContentRailPhysicalOffset.js +5 -0
  37. package/dist/patterns/content-rail/resolveContentRailPhysicalOffset.js.map +1 -0
  38. package/dist/patterns/content-rail/resolveContentRailState.d.ts +18 -0
  39. package/dist/patterns/content-rail/resolveContentRailState.d.ts.map +1 -0
  40. package/dist/patterns/content-rail/resolveContentRailState.js +21 -0
  41. package/dist/patterns/content-rail/resolveContentRailState.js.map +1 -0
  42. package/dist/patterns/content-rail/types.d.ts +31 -0
  43. package/dist/patterns/content-rail/types.d.ts.map +1 -0
  44. package/dist/patterns/content-rail/types.js +2 -0
  45. package/dist/patterns/content-rail/types.js.map +1 -0
  46. package/dist/patterns/content-rail/useContentRailReducedMotion.d.ts +3 -0
  47. package/dist/patterns/content-rail/useContentRailReducedMotion.d.ts.map +1 -0
  48. package/dist/patterns/content-rail/useContentRailReducedMotion.js +21 -0
  49. package/dist/patterns/content-rail/useContentRailReducedMotion.js.map +1 -0
  50. package/dist/patterns/message-bubble/meta.d.ts +1 -1
  51. package/dist/patterns/notice/meta.d.ts +1 -1
  52. package/dist/patterns/panel/meta.d.ts +1 -1
  53. package/dist/patterns/post-card/meta.d.ts +1 -1
  54. package/dist/patterns/reader/meta.d.ts +2 -2
  55. package/dist/patterns/scanner/meta.d.ts +2 -2
  56. package/dist/registry.d.ts.map +1 -1
  57. package/dist/registry.js +2 -0
  58. package/dist/registry.js.map +1 -1
  59. package/package.json +2 -2
  60. package/src/index.ts +10 -0
  61. package/src/interactionPolicy.test.ts +22 -0
  62. package/src/metadata/allowedChildren.ts +13 -0
  63. package/src/metadata/componentMeta.test.ts +34 -0
  64. package/src/metadata/componentMeta.ts +2 -0
  65. package/src/patterns/content-rail/ContentRail.test.ts +44 -0
  66. package/src/patterns/content-rail/ContentRail.tsx +245 -0
  67. package/src/patterns/content-rail/index.ts +10 -0
  68. package/src/patterns/content-rail/meta.ts +133 -0
  69. package/src/patterns/content-rail/resolveContentRailItemWidth.test.ts +77 -0
  70. package/src/patterns/content-rail/resolveContentRailItemWidth.ts +39 -0
  71. package/src/patterns/content-rail/resolveContentRailPhysicalOffset.test.ts +29 -0
  72. package/src/patterns/content-rail/resolveContentRailPhysicalOffset.ts +12 -0
  73. package/src/patterns/content-rail/resolveContentRailState.test.ts +68 -0
  74. package/src/patterns/content-rail/resolveContentRailState.ts +36 -0
  75. package/src/patterns/content-rail/types.ts +35 -0
  76. package/src/patterns/content-rail/useContentRailReducedMotion.ts +28 -0
  77. package/src/registry.ts +2 -0
@@ -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
+ });
@@ -0,0 +1,12 @@
1
+ export function resolveContentRailPhysicalOffset({
2
+ isRtl,
3
+ logicalOffset,
4
+ maxOffset,
5
+ }: {
6
+ isRtl: boolean;
7
+ logicalOffset: number;
8
+ maxOffset: number;
9
+ }): number {
10
+ const clampedOffset = Math.min(Math.max(0, logicalOffset), Math.max(0, maxOffset));
11
+ return isRtl ? Math.max(0, maxOffset) - clampedOffset : clampedOffset;
12
+ }
@@ -0,0 +1,68 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { resolveContentRailState } from './resolveContentRailState';
4
+
5
+ describe('resolveContentRailState', () => {
6
+ test('resolves overflow, visible items, and the nearest navigation anchor', () => {
7
+ expect(
8
+ resolveContentRailState({
9
+ contentWidth: 848,
10
+ gap: 16,
11
+ itemCount: 4,
12
+ itemWidth: 200,
13
+ logicalOffset: 216,
14
+ viewportWidth: 432,
15
+ }),
16
+ ).toEqual({
17
+ anchorIndex: 1,
18
+ canGoNext: true,
19
+ canGoPrevious: true,
20
+ firstVisibleIndex: 1,
21
+ lastVisibleIndex: 2,
22
+ maxOffset: 416,
23
+ offset: 216,
24
+ stride: 216,
25
+ });
26
+ });
27
+
28
+ test('clamps boundaries without wrapping', () => {
29
+ const start = resolveContentRailState({
30
+ contentWidth: 848,
31
+ gap: 16,
32
+ itemCount: 4,
33
+ itemWidth: 200,
34
+ logicalOffset: -100,
35
+ viewportWidth: 432,
36
+ });
37
+ const end = resolveContentRailState({
38
+ contentWidth: 848,
39
+ gap: 16,
40
+ itemCount: 4,
41
+ itemWidth: 200,
42
+ logicalOffset: 1000,
43
+ viewportWidth: 432,
44
+ });
45
+
46
+ expect(start).toMatchObject({ offset: 0, canGoPrevious: false, canGoNext: true });
47
+ expect(end).toMatchObject({ offset: 416, canGoPrevious: true, canGoNext: false });
48
+ });
49
+
50
+ test('degrades to a non-overflowing empty range', () => {
51
+ expect(
52
+ resolveContentRailState({
53
+ contentWidth: 0,
54
+ gap: 16,
55
+ itemCount: 0,
56
+ itemWidth: 0,
57
+ logicalOffset: 0,
58
+ viewportWidth: 432,
59
+ }),
60
+ ).toMatchObject({
61
+ canGoNext: false,
62
+ canGoPrevious: false,
63
+ firstVisibleIndex: -1,
64
+ lastVisibleIndex: -1,
65
+ maxOffset: 0,
66
+ });
67
+ });
68
+ });
@@ -0,0 +1,36 @@
1
+ export function resolveContentRailState({
2
+ contentWidth,
3
+ gap,
4
+ itemCount,
5
+ itemWidth,
6
+ logicalOffset,
7
+ viewportWidth,
8
+ }: {
9
+ contentWidth: number;
10
+ gap: number;
11
+ itemCount: number;
12
+ itemWidth: number;
13
+ logicalOffset: number;
14
+ viewportWidth: number;
15
+ }) {
16
+ const maxOffset = Math.max(0, contentWidth - viewportWidth);
17
+ const offset = Math.min(Math.max(0, logicalOffset), maxOffset);
18
+ const stride = itemWidth + gap;
19
+ const firstVisibleIndex = stride > 0 ? Math.min(itemCount - 1, Math.floor(offset / stride)) : 0;
20
+ const lastVisibleIndex =
21
+ stride > 0
22
+ ? Math.min(itemCount - 1, Math.max(0, Math.ceil((offset + viewportWidth) / stride) - 1))
23
+ : 0;
24
+ const anchorIndex = stride > 0 ? Math.round(offset / stride) : 0;
25
+
26
+ return {
27
+ anchorIndex: Math.min(Math.max(0, anchorIndex), Math.max(0, itemCount - 1)),
28
+ canGoNext: itemCount > 0 && offset < maxOffset - 1,
29
+ canGoPrevious: itemCount > 0 && offset > 1,
30
+ firstVisibleIndex: itemCount === 0 ? -1 : firstVisibleIndex,
31
+ lastVisibleIndex: itemCount === 0 ? -1 : lastVisibleIndex,
32
+ maxOffset,
33
+ offset,
34
+ stride,
35
+ };
36
+ }
@@ -0,0 +1,35 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ import type { ZoraBaseProps } from '../../theme/ZoraBaseProps';
4
+
5
+ export type ContentRailDirection = 'auto' | 'ltr' | 'rtl';
6
+ export type ContentRailItemSize = 'compact' | 'regular' | 'wide' | 'responsive';
7
+ export type ContentRailMotion = 'system' | 'animated' | 'reduced';
8
+ export type ContentRailSpacing = 'none' | 'xs' | 's' | 'm' | 'l' | 'xl';
9
+
10
+ export interface ContentRailControlPressEvent {
11
+ direction: 'previous' | 'next';
12
+ targetIndex: number;
13
+ }
14
+
15
+ export interface ContentRailVisibleRangeChangeEvent {
16
+ firstVisibleIndex: number;
17
+ lastVisibleIndex: number;
18
+ itemCount: number;
19
+ }
20
+
21
+ export interface ContentRailProps extends ZoraBaseProps {
22
+ children?: ReactNode;
23
+ itemSize?: ContentRailItemSize;
24
+ gap?: ContentRailSpacing;
25
+ padding?: ContentRailSpacing;
26
+ peek?: number;
27
+ showControls?: boolean;
28
+ direction?: ContentRailDirection;
29
+ motion?: ContentRailMotion;
30
+ accessibilityLabel?: string;
31
+ previousLabel?: string;
32
+ nextLabel?: string;
33
+ onControlPress?: (event: ContentRailControlPressEvent) => void | Promise<void>;
34
+ onVisibleRangeChange?: (event: ContentRailVisibleRangeChangeEvent) => void | Promise<void>;
35
+ }