@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.
- package/CHANGELOG.md +18 -0
- package/README.md +1 -1
- package/dist/components/card/meta.d.ts +1 -1
- package/dist/components/progress/ProgressRing.d.ts +7 -0
- package/dist/components/progress/ProgressRing.d.ts.map +1 -0
- package/dist/components/progress/ProgressRing.js +100 -0
- package/dist/components/progress/ProgressRing.js.map +1 -0
- package/dist/components/progress/index.d.ts +2 -1
- package/dist/components/progress/index.d.ts.map +1 -1
- package/dist/components/progress/index.js +1 -0
- package/dist/components/progress/index.js.map +1 -1
- package/dist/components/progress/meta.d.ts +116 -0
- package/dist/components/progress/meta.d.ts.map +1 -1
- package/dist/components/progress/meta.js +97 -0
- package/dist/components/progress/meta.js.map +1 -1
- package/dist/components/progress/resolveProgressRingGeometry.d.ts +12 -0
- package/dist/components/progress/resolveProgressRingGeometry.d.ts.map +1 -0
- package/dist/components/progress/resolveProgressRingGeometry.js +27 -0
- package/dist/components/progress/resolveProgressRingGeometry.js.map +1 -0
- package/dist/components/progress/types.d.ts +12 -0
- package/dist/components/progress/types.d.ts.map +1 -1
- package/dist/components/progress/types.js.map +1 -1
- package/dist/foundation/meta.d.ts +4 -4
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/layout/screen/meta.d.ts +1 -1
- package/dist/layout/screen-section/meta.d.ts +1 -1
- package/dist/metadata/allowedChildren.d.ts +4 -3
- package/dist/metadata/allowedChildren.d.ts.map +1 -1
- package/dist/metadata/allowedChildren.js +13 -0
- package/dist/metadata/allowedChildren.js.map +1 -1
- package/dist/metadata/bindableComponentMeta.d.ts +2 -2
- package/dist/metadata/componentMeta.d.ts.map +1 -1
- package/dist/metadata/componentMeta.js +4 -1
- package/dist/metadata/componentMeta.js.map +1 -1
- package/dist/patterns/content-rail/ContentRail.d.ts +10 -0
- package/dist/patterns/content-rail/ContentRail.d.ts.map +1 -0
- package/dist/patterns/content-rail/ContentRail.js +175 -0
- package/dist/patterns/content-rail/ContentRail.js.map +1 -0
- package/dist/patterns/content-rail/index.d.ts +3 -0
- package/dist/patterns/content-rail/index.d.ts.map +1 -0
- package/dist/patterns/content-rail/index.js +2 -0
- package/dist/patterns/content-rail/index.js.map +1 -0
- package/dist/patterns/content-rail/meta.d.ts +143 -0
- package/dist/patterns/content-rail/meta.d.ts.map +1 -0
- package/dist/patterns/content-rail/meta.js +130 -0
- package/dist/patterns/content-rail/meta.js.map +1 -0
- package/dist/patterns/content-rail/resolveContentRailItemWidth.d.ts +10 -0
- package/dist/patterns/content-rail/resolveContentRailItemWidth.d.ts.map +1 -0
- package/dist/patterns/content-rail/resolveContentRailItemWidth.js +25 -0
- package/dist/patterns/content-rail/resolveContentRailItemWidth.js.map +1 -0
- package/dist/patterns/content-rail/resolveContentRailPhysicalOffset.d.ts +6 -0
- package/dist/patterns/content-rail/resolveContentRailPhysicalOffset.d.ts.map +1 -0
- package/dist/patterns/content-rail/resolveContentRailPhysicalOffset.js +5 -0
- package/dist/patterns/content-rail/resolveContentRailPhysicalOffset.js.map +1 -0
- package/dist/patterns/content-rail/resolveContentRailState.d.ts +18 -0
- package/dist/patterns/content-rail/resolveContentRailState.d.ts.map +1 -0
- package/dist/patterns/content-rail/resolveContentRailState.js +21 -0
- package/dist/patterns/content-rail/resolveContentRailState.js.map +1 -0
- package/dist/patterns/content-rail/types.d.ts +31 -0
- package/dist/patterns/content-rail/types.d.ts.map +1 -0
- package/dist/patterns/content-rail/types.js +2 -0
- package/dist/patterns/content-rail/types.js.map +1 -0
- package/dist/patterns/content-rail/useContentRailReducedMotion.d.ts +3 -0
- package/dist/patterns/content-rail/useContentRailReducedMotion.d.ts.map +1 -0
- package/dist/patterns/content-rail/useContentRailReducedMotion.js +21 -0
- package/dist/patterns/content-rail/useContentRailReducedMotion.js.map +1 -0
- package/dist/patterns/message-bubble/meta.d.ts +1 -1
- package/dist/patterns/notice/meta.d.ts +1 -1
- package/dist/patterns/panel/meta.d.ts +1 -1
- package/dist/patterns/post-card/meta.d.ts +1 -1
- package/dist/patterns/reader/meta.d.ts +2 -2
- package/dist/patterns/scanner/meta.d.ts +2 -2
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +4 -1
- package/dist/registry.js.map +1 -1
- package/package.json +2 -2
- package/src/components/progress/ProgressRing.test.ts +48 -0
- package/src/components/progress/ProgressRing.tsx +151 -0
- package/src/components/progress/index.ts +2 -1
- package/src/components/progress/meta.ts +99 -0
- package/src/components/progress/resolveProgressRingGeometry.test.ts +30 -0
- package/src/components/progress/resolveProgressRingGeometry.ts +44 -0
- package/src/components/progress/types.ts +13 -0
- package/src/index.ts +12 -2
- package/src/interactionPolicy.test.ts +22 -0
- package/src/metadata/allowedChildren.ts +14 -0
- package/src/metadata/componentMeta.test.ts +51 -0
- package/src/metadata/componentMeta.ts +4 -1
- package/src/patterns/content-rail/ContentRail.test.ts +44 -0
- package/src/patterns/content-rail/ContentRail.tsx +245 -0
- package/src/patterns/content-rail/index.ts +10 -0
- package/src/patterns/content-rail/meta.ts +133 -0
- package/src/patterns/content-rail/resolveContentRailItemWidth.test.ts +77 -0
- package/src/patterns/content-rail/resolveContentRailItemWidth.ts +39 -0
- package/src/patterns/content-rail/resolveContentRailPhysicalOffset.test.ts +29 -0
- package/src/patterns/content-rail/resolveContentRailPhysicalOffset.ts +12 -0
- package/src/patterns/content-rail/resolveContentRailState.test.ts +68 -0
- package/src/patterns/content-rail/resolveContentRailState.ts +36 -0
- package/src/patterns/content-rail/types.ts +35 -0
- package/src/patterns/content-rail/useContentRailReducedMotion.ts +28 -0
- package/src/registry.ts +4 -1
- 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
|
+
});
|