@ankhorage/zora 2.6.1 → 2.7.0

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 (43) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -1
  3. package/dist/index.d.ts +2 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +1 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/metadata/componentMeta.d.ts.map +1 -1
  8. package/dist/metadata/componentMeta.js +4 -0
  9. package/dist/metadata/componentMeta.js.map +1 -1
  10. package/dist/patterns/scanner/BarcodeScannerView.d.ts +11 -0
  11. package/dist/patterns/scanner/BarcodeScannerView.d.ts.map +1 -0
  12. package/dist/patterns/scanner/BarcodeScannerView.js +77 -0
  13. package/dist/patterns/scanner/BarcodeScannerView.js.map +1 -0
  14. package/dist/patterns/scanner/CameraPermissionView.d.ts +11 -0
  15. package/dist/patterns/scanner/CameraPermissionView.d.ts.map +1 -0
  16. package/dist/patterns/scanner/CameraPermissionView.js +62 -0
  17. package/dist/patterns/scanner/CameraPermissionView.js.map +1 -0
  18. package/dist/patterns/scanner/ScanOverlay.d.ts +11 -0
  19. package/dist/patterns/scanner/ScanOverlay.d.ts.map +1 -0
  20. package/dist/patterns/scanner/ScanOverlay.js +85 -0
  21. package/dist/patterns/scanner/ScanOverlay.js.map +1 -0
  22. package/dist/patterns/scanner/index.d.ts +5 -0
  23. package/dist/patterns/scanner/index.d.ts.map +1 -0
  24. package/dist/patterns/scanner/index.js +4 -0
  25. package/dist/patterns/scanner/index.js.map +1 -0
  26. package/dist/patterns/scanner/meta.d.ts +190 -0
  27. package/dist/patterns/scanner/meta.d.ts.map +1 -0
  28. package/dist/patterns/scanner/meta.js +186 -0
  29. package/dist/patterns/scanner/meta.js.map +1 -0
  30. package/dist/patterns/scanner/types.d.ts +42 -0
  31. package/dist/patterns/scanner/types.d.ts.map +1 -0
  32. package/dist/patterns/scanner/types.js +2 -0
  33. package/dist/patterns/scanner/types.js.map +1 -0
  34. package/package.json +1 -1
  35. package/src/index.ts +8 -0
  36. package/src/metadata/componentMeta.test.ts +30 -105
  37. package/src/metadata/componentMeta.ts +8 -0
  38. package/src/patterns/scanner/BarcodeScannerView.tsx +127 -0
  39. package/src/patterns/scanner/CameraPermissionView.tsx +108 -0
  40. package/src/patterns/scanner/ScanOverlay.tsx +99 -0
  41. package/src/patterns/scanner/index.ts +10 -0
  42. package/src/patterns/scanner/meta.ts +190 -0
  43. package/src/patterns/scanner/types.ts +47 -0
@@ -1,117 +1,43 @@
1
- import { readFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
-
4
1
  import { describe, expect, test } from 'bun:test';
5
2
 
6
- import { ZORA_COMPONENT_META } from './componentMeta';
7
- import type { ZoraComponentEventPayloadKind } from './types';
8
-
9
- const ROOT = process.cwd();
10
- const SRC_ROOT = join(ROOT, 'src');
11
-
12
- const INDEX_PATH = join(SRC_ROOT, 'index.ts');
13
- const THEME_INDEX_PATH = join(SRC_ROOT, 'theme', 'index.ts');
14
-
15
- function parseReexportBlocks(source: string): { names: string[]; from: string; isType: boolean }[] {
16
- const pattern = /\bexport\s+(type\s+)?\{([\s\S]*?)\}\s*from\s*['"]([^'"]+)['"]/g;
17
- const blocks: { names: string[]; from: string; isType: boolean }[] = [];
18
-
19
- for (const match of source.matchAll(pattern)) {
20
- const isType = match[1] !== undefined;
21
- const rawNames = match[2] ?? '';
22
- const from = match[3] ?? '';
23
-
24
- const names = rawNames
25
- .split(',')
26
- .map((item) => item.trim())
27
- .filter(Boolean)
28
- .map((item) =>
29
- item
30
- .replace(/^type\s+/, '')
31
- .split(/\s+as\s+/)[0]
32
- ?.trim(),
33
- )
34
- .filter((item): item is string => Boolean(item));
35
-
36
- blocks.push({ names, from, isType });
37
- }
38
-
39
- return blocks;
40
- }
41
-
42
- function readSource(filePath: string): string {
43
- return readFileSync(filePath, 'utf8');
44
- }
45
-
46
- function isComponentExportName(name: string): boolean {
47
- return /^[A-Z][A-Za-z0-9]*$/.test(name);
48
- }
49
-
50
- function isJsonSerializable(value: unknown): boolean {
51
- if (value === null) return true;
52
- if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean')
53
- return true;
54
- if (Array.isArray(value)) return value.every((item) => isJsonSerializable(item));
55
- if (typeof value !== 'object') return false;
56
-
57
- const record = value as Record<string, unknown>;
58
- return Object.values(record).every((item) => isJsonSerializable(item));
59
- }
3
+ import { ZORA_COMPONENT_META, type ZoraComponentEventPayloadKind } from './index';
60
4
 
61
5
  describe('ZORA_COMPONENT_META public API', () => {
62
6
  test('src/index.ts re-exports ZORA_COMPONENT_META', () => {
63
- const indexSource = readSource(INDEX_PATH);
64
- expect(indexSource).toContain('ZORA_COMPONENT_META');
7
+ expect(ZORA_COMPONENT_META).toBeDefined();
65
8
  });
66
9
 
67
10
  test('src/index.ts re-exports component event metadata types', () => {
68
- const indexSource = readSource(INDEX_PATH);
69
- expect(indexSource).toContain('ZoraComponentEventMeta');
70
- expect(indexSource).toContain('ZoraComponentEventPayloadKind');
11
+ const eventType: ZoraComponentEventPayloadKind = 'button.press';
12
+
13
+ expect(eventType).toBe('button.press');
71
14
  });
72
15
  });
73
16
 
74
17
  describe('ZORA_COMPONENT_META registry coverage', () => {
75
- test('covers every public UI React component export (foundation/components/patterns/layout)', () => {
76
- // Avoid importing the full src/index.ts barrel here; Bun has been observed to crash when executing
77
- // module graphs that touch react-native. We do drift checks via static source parsing instead.
78
- const indexSource = readSource(INDEX_PATH);
79
- const blocks = parseReexportBlocks(indexSource).filter((block) => !block.isType);
80
-
81
- const uiComponentNames = new Set(
82
- blocks
83
- .filter((block) => {
84
- return (
85
- block.from.startsWith('./foundation') ||
86
- block.from.startsWith('./components') ||
87
- block.from.startsWith('./patterns') ||
88
- block.from.startsWith('./layout')
89
- );
90
- })
91
- .flatMap((block) => block.names)
92
- .filter(isComponentExportName),
93
- );
94
-
95
- const registryKeys = new Set(Object.keys(ZORA_COMPONENT_META));
96
- expect([...uiComponentNames].sort()).toEqual([...registryKeys].sort());
18
+ test('covers every public UI React component export (foundation/components/patterns/layout)', async () => {
19
+ const source = await Bun.file('src/index.ts').text();
20
+ const componentExports = Array.from(
21
+ source.matchAll(
22
+ /export \{ ([^}]+) \} from '\.\/(components|foundation|layout|patterns)\/[^']+';/g,
23
+ ),
24
+ )
25
+ .flatMap((match) => match[1].split(',').map((item) => item.trim()))
26
+ .map((item) => item.split(' as ')[0].trim())
27
+ .filter((name) => /^[A-Z]/.test(name))
28
+ .filter((name) => !['ToastProvider'].includes(name));
29
+
30
+ for (const name of componentExports) {
31
+ expect(
32
+ ZORA_COMPONENT_META[name],
33
+ `${name} is missing from ZORA_COMPONENT_META`,
34
+ ).toBeDefined();
35
+ }
97
36
  });
98
37
 
99
- test('does not treat providers/scopes as UI component registry entries', () => {
100
- const indexSource = readSource(INDEX_PATH);
101
- expect(indexSource).toContain("export * from './theme';");
102
-
103
- const themeSource = readSource(THEME_INDEX_PATH);
104
- const themeBlocks = parseReexportBlocks(themeSource).filter((block) => !block.isType);
105
- const themeValueExports = new Set(
106
- themeBlocks.flatMap((block) => block.names).filter(isComponentExportName),
107
- );
108
-
109
- const providerExports = new Set(['ZoraProvider', 'ZoraThemeScope']);
110
-
111
- for (const providerExport of providerExports) {
112
- expect(themeValueExports.has(providerExport)).toBe(true);
113
- expect(Object.prototype.hasOwnProperty.call(ZORA_COMPONENT_META, providerExport)).toBe(false);
114
- }
38
+ test('does not treat providers/scopes as direct manifest UI nodes', () => {
39
+ expect(ZORA_COMPONENT_META.ToastProvider?.directManifestNode).toBe(false);
40
+ expect(ZORA_COMPONENT_META.ZoraProvider).toBeUndefined();
115
41
  });
116
42
  });
117
43
 
@@ -177,6 +103,8 @@ describe('ZORA_COMPONENT_META invariants', () => {
177
103
  'Heading',
178
104
  'Divider',
179
105
  'ChatListItem',
106
+ 'CameraPermissionView',
107
+ 'ScanOverlay',
180
108
  'Progress',
181
109
  ]);
182
110
 
@@ -194,6 +122,7 @@ describe('ZORA_COMPONENT_META invariants', () => {
194
122
  'Stack',
195
123
  'Grid',
196
124
  'Container',
125
+ 'BarcodeScannerView',
197
126
  ]);
198
127
 
199
128
  for (const [key, meta] of Object.entries(ZORA_COMPONENT_META)) {
@@ -245,11 +174,7 @@ describe('ZORA_COMPONENT_META invariants', () => {
245
174
 
246
175
  for (const [key, meta] of Object.entries(ZORA_COMPONENT_META)) {
247
176
  if (!meta.blueprint?.defaultProps) continue;
248
-
249
- expect(
250
- isJsonSerializable(meta.blueprint.defaultProps),
251
- `${key} blueprint.defaultProps contains a non-serializable value`,
252
- ).toBe(true);
177
+ expect(() => JSON.stringify(meta.blueprint.defaultProps), key).not.toThrow();
253
178
  }
254
179
  });
255
180
  });
@@ -76,6 +76,11 @@ import { noticeMeta } from '../patterns/notice/meta';
76
76
  import { panelMeta } from '../patterns/panel/meta';
77
77
  import { postCardMeta } from '../patterns/post-card/meta';
78
78
  import { responsivePanelMeta } from '../patterns/responsive-panel/meta';
79
+ import {
80
+ barcodeScannerViewMeta,
81
+ cameraPermissionViewMeta,
82
+ scanOverlayMeta,
83
+ } from '../patterns/scanner/meta';
79
84
  import { sectionHeaderMeta } from '../patterns/section-header/meta';
80
85
  import { selectableItemMeta, selectionProviderMeta } from '../patterns/selection/meta';
81
86
  import { settingsRowMeta } from '../patterns/settings-row/meta';
@@ -174,6 +179,9 @@ export const ZORA_COMPONENT_META: ZoraComponentMetaRegistry = {
174
179
  Panel: panelMeta,
175
180
  PostCard: postCardMeta,
176
181
  ResponsivePanel: responsivePanelMeta,
182
+ BarcodeScannerView: barcodeScannerViewMeta,
183
+ CameraPermissionView: cameraPermissionViewMeta,
184
+ ScanOverlay: scanOverlayMeta,
177
185
  SectionHeader: sectionHeaderMeta,
178
186
  SelectableItem: selectableItemMeta,
179
187
  SelectionProvider: selectionProviderMeta,
@@ -0,0 +1,127 @@
1
+ import React from 'react';
2
+ import { StyleSheet, View } from 'react-native';
3
+
4
+ import { Card } from '../../components/card';
5
+ import { Text } from '../../components/text';
6
+ import { Box, Stack } from '../../foundation';
7
+ import { useZoraTheme } from '../../theme/useZoraTheme';
8
+ import { withZoraThemeScope } from '../../theme/withZoraThemeScope';
9
+ import { CameraPermissionView } from './CameraPermissionView';
10
+ import { ScanOverlay } from './ScanOverlay';
11
+ import type { BarcodeScannerViewProps } from './types';
12
+
13
+ function BarcodeScannerViewInner({
14
+ themeId: _themeId,
15
+ mode: _mode,
16
+ permissionStatus,
17
+ camera,
18
+ children,
19
+ title = 'Scan barcode',
20
+ description = 'Point the camera at a barcode to continue.',
21
+ overlayTitle,
22
+ overlayDescription,
23
+ cornerLabel,
24
+ requestPermissionLabel,
25
+ deniedPermissionLabel,
26
+ manualEntryLabel,
27
+ onRequestPermission,
28
+ onManualEntry,
29
+ testID,
30
+ }: BarcodeScannerViewProps) {
31
+ const { theme } = useZoraTheme();
32
+ const viewportStyle = {
33
+ backgroundColor: theme.semantics.surface.default,
34
+ };
35
+ const placeholderStyle = {
36
+ backgroundColor: theme.semantics.surface.default,
37
+ };
38
+
39
+ if (permissionStatus !== 'granted') {
40
+ return (
41
+ <CameraPermissionView
42
+ deniedLabel={deniedPermissionLabel}
43
+ manualEntryLabel={manualEntryLabel}
44
+ onManualEntry={onManualEntry}
45
+ onRequestPermission={onRequestPermission}
46
+ requestLabel={requestPermissionLabel}
47
+ status={permissionStatus}
48
+ testID={testID}
49
+ />
50
+ );
51
+ }
52
+
53
+ return (
54
+ <Card
55
+ compact
56
+ description={description}
57
+ eyebrow="Scanner"
58
+ testID={testID}
59
+ title={title}
60
+ tone="subtle"
61
+ >
62
+ <Stack gap="m">
63
+ <Box>
64
+ <View style={[styles.viewport, viewportStyle]}>
65
+ {camera ? (
66
+ <View style={styles.cameraSlot}>{camera}</View>
67
+ ) : (
68
+ <View style={[styles.placeholder, placeholderStyle]} />
69
+ )}
70
+ <View pointerEvents="none" style={styles.overlay}>
71
+ <ScanOverlay
72
+ cornerLabel={cornerLabel}
73
+ description={overlayDescription}
74
+ title={overlayTitle}
75
+ />
76
+ </View>
77
+ </View>
78
+ </Box>
79
+ {children ? <Box>{children}</Box> : null}
80
+ <Text emphasis="muted" variant="caption">
81
+ Native scanning belongs to an app adapter such as expo-camera. ZORA owns this visible
82
+ scanner surface.
83
+ </Text>
84
+ </Stack>
85
+ </Card>
86
+ );
87
+ }
88
+
89
+ const styles = StyleSheet.create({
90
+ viewport: {
91
+ aspectRatio: 0.72,
92
+ borderRadius: 28,
93
+ minHeight: 360,
94
+ overflow: 'hidden',
95
+ position: 'relative',
96
+ },
97
+ cameraSlot: {
98
+ bottom: 0,
99
+ left: 0,
100
+ position: 'absolute',
101
+ right: 0,
102
+ top: 0,
103
+ },
104
+ placeholder: {
105
+ bottom: 0,
106
+ left: 0,
107
+ position: 'absolute',
108
+ right: 0,
109
+ top: 0,
110
+ },
111
+ overlay: {
112
+ bottom: 24,
113
+ left: 24,
114
+ position: 'absolute',
115
+ right: 24,
116
+ top: 24,
117
+ },
118
+ });
119
+
120
+ /***
121
+ * Composed ZORA scanner shell for barcode scanning experiences.
122
+ *
123
+ * `BarcodeScannerView` is camera-adapter-neutral: pass an `expo-camera`, web,
124
+ * or test camera element through `camera`; keep permission and fallback UI here.
125
+ *
126
+ */
127
+ export const BarcodeScannerView = withZoraThemeScope(BarcodeScannerViewInner);
@@ -0,0 +1,108 @@
1
+ import React from 'react';
2
+
3
+ import { Button } from '../../components/button';
4
+ import { Card } from '../../components/card';
5
+ import { Text } from '../../components/text';
6
+ import { Stack } from '../../foundation';
7
+ import { withZoraThemeScope } from '../../theme/withZoraThemeScope';
8
+ import type { CameraPermissionViewProps } from './types';
9
+
10
+ function resolvePermissionCopy(status: CameraPermissionViewProps['status']) {
11
+ if (status === 'denied') {
12
+ return {
13
+ title: 'Camera permission needed',
14
+ description:
15
+ 'Camera access is denied. Enable camera access in system settings or enter the barcode manually.',
16
+ };
17
+ }
18
+
19
+ if (status === 'requesting') {
20
+ return {
21
+ title: 'Requesting camera access',
22
+ description: 'Waiting for the system permission prompt.',
23
+ };
24
+ }
25
+
26
+ return {
27
+ title: 'Allow camera access',
28
+ description:
29
+ 'Camera access is required to scan barcodes. You can also enter the barcode manually.',
30
+ };
31
+ }
32
+
33
+ function CameraPermissionViewInner({
34
+ themeId: _themeId,
35
+ mode: _mode,
36
+ status,
37
+ title,
38
+ description,
39
+ requestLabel = 'Allow camera access',
40
+ deniedLabel = 'Open settings',
41
+ manualEntryLabel = 'Enter barcode manually',
42
+ onRequestPermission,
43
+ onManualEntry,
44
+ requestButtonProps,
45
+ manualEntryButtonProps,
46
+ testID,
47
+ }: CameraPermissionViewProps) {
48
+ const copy = resolvePermissionCopy(status);
49
+ const primaryLabel = status === 'denied' ? deniedLabel : requestLabel;
50
+ const handleRequestPermission = onRequestPermission
51
+ ? () => {
52
+ void onRequestPermission();
53
+ }
54
+ : undefined;
55
+ const handleManualEntry = onManualEntry
56
+ ? () => {
57
+ void onManualEntry();
58
+ }
59
+ : undefined;
60
+
61
+ return (
62
+ <Card
63
+ compact
64
+ description={description ?? copy.description}
65
+ eyebrow="Scanner permission"
66
+ testID={testID}
67
+ title={title ?? copy.title}
68
+ tone="subtle"
69
+ >
70
+ <Stack gap="m">
71
+ <Text emphasis="muted" variant="bodySmall">
72
+ Permission state: {status}
73
+ </Text>
74
+ <Stack direction={{ base: 'column', md: 'row' }} gap="s">
75
+ {handleRequestPermission ? (
76
+ <Button
77
+ color="primary"
78
+ disabled={status === 'requesting'}
79
+ onPress={handleRequestPermission}
80
+ {...requestButtonProps}
81
+ >
82
+ {primaryLabel}
83
+ </Button>
84
+ ) : null}
85
+ {handleManualEntry ? (
86
+ <Button
87
+ color="neutral"
88
+ onPress={handleManualEntry}
89
+ variant="soft"
90
+ {...manualEntryButtonProps}
91
+ >
92
+ {manualEntryLabel}
93
+ </Button>
94
+ ) : null}
95
+ </Stack>
96
+ </Stack>
97
+ </Card>
98
+ );
99
+ }
100
+
101
+ /***
102
+ * ZORA-owned camera permission state for scanner flows.
103
+ *
104
+ * Native permission APIs stay in app adapters. This component renders the
105
+ * request, denied, requesting, and manual-entry surfaces consistently.
106
+ *
107
+ */
108
+ export const CameraPermissionView = withZoraThemeScope(CameraPermissionViewInner);
@@ -0,0 +1,99 @@
1
+ import React from 'react';
2
+ import { StyleSheet, View } from 'react-native';
3
+
4
+ import { Badge } from '../../components/badge';
5
+ import { Text } from '../../components/text';
6
+ import { Box, Stack } from '../../foundation';
7
+ import { useZoraTheme } from '../../theme/useZoraTheme';
8
+ import { withZoraThemeScope } from '../../theme/withZoraThemeScope';
9
+ import type { ScanOverlayProps } from './types';
10
+
11
+ function ScanOverlayInner({
12
+ themeId: _themeId,
13
+ mode: _mode,
14
+ title = 'Align the barcode',
15
+ description = 'Hold the barcode inside the frame. Scanning starts automatically.',
16
+ cornerLabel = 'SCAN',
17
+ testID,
18
+ }: ScanOverlayProps) {
19
+ const { theme } = useZoraTheme();
20
+ const frameStyle = {
21
+ borderColor: theme.semantics.border.default,
22
+ };
23
+ const cornerStyle = {
24
+ borderColor: theme.semantics.border.focus,
25
+ };
26
+
27
+ return (
28
+ <Box testID={testID}>
29
+ <Stack gap="m">
30
+ <View accessibilityRole="image" style={[styles.frame, frameStyle]}>
31
+ <View style={[styles.corner, styles.topLeft, cornerStyle]} />
32
+ <View style={[styles.corner, styles.topRight, cornerStyle]} />
33
+ <View style={[styles.corner, styles.bottomLeft, cornerStyle]} />
34
+ <View style={[styles.corner, styles.bottomRight, cornerStyle]} />
35
+ <Badge color="primary">{cornerLabel}</Badge>
36
+ </View>
37
+ <Stack gap="xs">
38
+ <Text align="center" variant="label" weight="semiBold">
39
+ {title}
40
+ </Text>
41
+ <Text align="center" emphasis="muted" variant="bodySmall">
42
+ {description}
43
+ </Text>
44
+ </Stack>
45
+ </Stack>
46
+ </Box>
47
+ );
48
+ }
49
+
50
+ const styles = StyleSheet.create({
51
+ frame: {
52
+ alignItems: 'center',
53
+ aspectRatio: 1.55,
54
+ borderRadius: 28,
55
+ borderWidth: 1,
56
+ justifyContent: 'center',
57
+ minHeight: 180,
58
+ overflow: 'hidden',
59
+ position: 'relative',
60
+ },
61
+ corner: {
62
+ height: 34,
63
+ position: 'absolute',
64
+ width: 34,
65
+ },
66
+ topLeft: {
67
+ borderLeftWidth: 4,
68
+ borderTopWidth: 4,
69
+ left: 18,
70
+ top: 18,
71
+ },
72
+ topRight: {
73
+ borderRightWidth: 4,
74
+ borderTopWidth: 4,
75
+ right: 18,
76
+ top: 18,
77
+ },
78
+ bottomLeft: {
79
+ borderBottomWidth: 4,
80
+ borderLeftWidth: 4,
81
+ bottom: 18,
82
+ left: 18,
83
+ },
84
+ bottomRight: {
85
+ borderBottomWidth: 4,
86
+ borderRightWidth: 4,
87
+ bottom: 18,
88
+ right: 18,
89
+ },
90
+ });
91
+
92
+ /***
93
+ * Camera-agnostic scan frame overlay for barcode and QR scanning flows.
94
+ *
95
+ * `ScanOverlay` intentionally renders no native camera. Apps provide camera
96
+ * capability separately while ZORA owns the visible scan affordance.
97
+ *
98
+ */
99
+ export const ScanOverlay = withZoraThemeScope(ScanOverlayInner);
@@ -0,0 +1,10 @@
1
+ export { BarcodeScannerView } from './BarcodeScannerView';
2
+ export { CameraPermissionView } from './CameraPermissionView';
3
+ export { ScanOverlay } from './ScanOverlay';
4
+ export type {
5
+ BarcodeScannerViewProps,
6
+ BarcodeScanResult,
7
+ CameraPermissionStatus,
8
+ CameraPermissionViewProps,
9
+ ScanOverlayProps,
10
+ } from './types';