@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.
- package/CHANGELOG.md +18 -0
- package/dist/components/card/meta.d.ts +1 -1
- package/dist/foundation/meta.d.ts +4 -4
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- 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 +14 -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 -0
- package/dist/metadata/componentMeta.js.map +1 -1
- package/dist/metadata/index.d.ts +1 -1
- package/dist/metadata/index.d.ts.map +1 -1
- package/dist/metadata/index.js.map +1 -1
- package/dist/metadata/types.d.ts +6 -0
- package/dist/metadata/types.d.ts.map +1 -1
- package/dist/metadata/types.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/missing-element/MissingElement.d.ts +10 -0
- package/dist/patterns/missing-element/MissingElement.d.ts.map +1 -0
- package/dist/patterns/missing-element/MissingElement.js +41 -0
- package/dist/patterns/missing-element/MissingElement.js.map +1 -0
- package/dist/patterns/missing-element/index.d.ts +3 -0
- package/dist/patterns/missing-element/index.d.ts.map +1 -0
- package/dist/patterns/missing-element/index.js +2 -0
- package/dist/patterns/missing-element/index.js.map +1 -0
- package/dist/patterns/missing-element/meta.d.ts +68 -0
- package/dist/patterns/missing-element/meta.d.ts.map +1 -0
- package/dist/patterns/missing-element/meta.js +59 -0
- package/dist/patterns/missing-element/meta.js.map +1 -0
- package/dist/patterns/missing-element/types.d.ts +9 -0
- package/dist/patterns/missing-element/types.d.ts.map +1 -0
- package/dist/patterns/missing-element/types.js +2 -0
- package/dist/patterns/missing-element/types.js.map +1 -0
- 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 -0
- package/dist/registry.js.map +1 -1
- package/package.json +3 -3
- package/src/expo57Migration.test.ts +16 -1
- package/src/index.ts +13 -0
- package/src/interactionPolicy.test.ts +22 -0
- package/src/metadata/allowedChildren.ts +15 -0
- package/src/metadata/componentMeta.test.ts +50 -0
- package/src/metadata/componentMeta.ts +4 -0
- package/src/metadata/index.ts +1 -0
- package/src/metadata/types.ts +7 -0
- 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/patterns/missing-element/MissingElement.test.ts +66 -0
- package/src/patterns/missing-element/MissingElement.tsx +73 -0
- package/src/patterns/missing-element/index.ts +2 -0
- package/src/patterns/missing-element/meta.ts +62 -0
- package/src/patterns/missing-element/types.ts +9 -0
- 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(
|
|
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,
|
package/src/metadata/index.ts
CHANGED
package/src/metadata/types.ts
CHANGED
|
@@ -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;
|