@ankhorage/zora 25.1.0 → 25.2.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 +13 -0
- package/README.md +1 -1
- package/dist/features/explorer/adapters/inbound/Explorer.d.ts.map +1 -1
- package/dist/features/explorer/adapters/inbound/Explorer.js +43 -2
- package/dist/features/explorer/adapters/inbound/Explorer.js.map +1 -1
- package/dist/features/explorer/application/shouldDispatchExplorerPageRequest.d.ts +8 -0
- package/dist/features/explorer/application/shouldDispatchExplorerPageRequest.d.ts.map +1 -0
- package/dist/features/explorer/application/shouldDispatchExplorerPageRequest.js +9 -0
- package/dist/features/explorer/application/shouldDispatchExplorerPageRequest.js.map +1 -0
- package/dist/features/explorer/application/shouldRequestExplorerPage.d.ts +4 -0
- package/dist/features/explorer/application/shouldRequestExplorerPage.d.ts.map +1 -0
- package/dist/features/explorer/application/shouldRequestExplorerPage.js +14 -0
- package/dist/features/explorer/application/shouldRequestExplorerPage.js.map +1 -0
- package/dist/features/explorer/meta.d.ts +110 -0
- package/dist/features/explorer/meta.d.ts.map +1 -1
- package/dist/features/explorer/meta.js +25 -0
- package/dist/features/explorer/meta.js.map +1 -1
- package/dist/features/explorer/public.d.ts +1 -1
- package/dist/features/explorer/public.d.ts.map +1 -1
- package/dist/features/explorer/public.js.map +1 -1
- package/dist/types/explorer.d.ts +28 -0
- package/dist/types/explorer.d.ts.map +1 -1
- package/dist/types/explorer.js.map +1 -1
- package/package.json +5 -5
- package/src/capabilities/index.test.ts +10 -6
- package/src/features/explorer/adapters/inbound/Explorer.tsx +82 -13
- package/src/features/explorer/application/shouldDispatchExplorerPageRequest.test.ts +43 -0
- package/src/features/explorer/application/shouldDispatchExplorerPageRequest.ts +18 -0
- package/src/features/explorer/application/shouldRequestExplorerPage.test.ts +93 -0
- package/src/features/explorer/application/shouldRequestExplorerPage.ts +26 -0
- package/src/features/explorer/meta.test.ts +55 -0
- package/src/features/explorer/meta.ts +25 -0
- package/src/features/explorer/public.ts +2 -0
- package/src/types/explorer.ts +30 -0
- package/web-dist/chunks/{Accordion-fmn31fgf.js → Accordion-kkr7xfh2.js} +88 -12
- package/web-dist/components/explorer/Explorer.js +1 -1
- package/web-dist/components/file-explorer/FileExplorer.js +1 -1
- package/web-dist/components/media-explorer/MediaExplorer.js +1 -1
- package/web-dist/components/uploader/Uploader.js +1 -1
- package/web-dist/manifest.json +4 -4
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ankhorage/zora",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "25.1
|
|
4
|
+
"version": "25.2.1",
|
|
5
5
|
"description": "Opinionated React Native and React Native Web UI kit built on @ankhorage/surface.",
|
|
6
6
|
"homepage": "https://github.com/ankhorage/zora#readme",
|
|
7
7
|
"bugs": {
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"react-native-worklets": "0.10.1"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@ankhorage/ankh": "^0.
|
|
43
|
+
"@ankhorage/ankh": "^0.13.0",
|
|
44
44
|
"@ankhorage/color-theory": "^0.3.1",
|
|
45
45
|
"@ankhorage/contracts": "^24.2.1",
|
|
46
46
|
"@ankhorage/game": "^0.1.2",
|
|
@@ -132,20 +132,20 @@
|
|
|
132
132
|
"version-packages": "ankhorage-changeset version"
|
|
133
133
|
},
|
|
134
134
|
"devDependencies": {
|
|
135
|
-
"@ankhorage/devtools": "^2.3.
|
|
135
|
+
"@ankhorage/devtools": "^2.3.9",
|
|
136
136
|
"@ankhorage/paradox": "^0.3.1",
|
|
137
137
|
"@react-native-vector-icons/fontawesome": "^13.1.4",
|
|
138
138
|
"@react-native-vector-icons/fontawesome5": "^13.1.4",
|
|
139
139
|
"@react-native-vector-icons/fontawesome6": "^13.1.4",
|
|
140
140
|
"@react-native-vector-icons/ionicons": "^13.1.4",
|
|
141
141
|
"@types/bun": "^1.4.2",
|
|
142
|
-
"@types/node": "^26.6.
|
|
142
|
+
"@types/node": "^26.6.5",
|
|
143
143
|
"@types/react": "^19.3.0",
|
|
144
144
|
"@types/react-dom": "^19.3.0",
|
|
145
145
|
"@types/react-native-web": "^0.19.3",
|
|
146
146
|
"cytoscape": "^3.34.3",
|
|
147
147
|
"cytoscape-elk": "^2.3.0",
|
|
148
|
-
"happy-dom": "^20.14.
|
|
148
|
+
"happy-dom": "^20.14.6",
|
|
149
149
|
"react": "19.2.3",
|
|
150
150
|
"react-dom": "19.2.3",
|
|
151
151
|
"react-native": "0.86.3",
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
|
|
3
3
|
import { isCapability } from '@ankhorage/contracts/capabilities';
|
|
4
|
+
import { isRecord } from '@ankhorage/utility/object';
|
|
4
5
|
import { expect, test } from 'bun:test';
|
|
5
6
|
|
|
6
7
|
import { CAPABILITIES } from './index';
|
|
7
8
|
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
).ankh
|
|
9
|
+
const packageJson: unknown = JSON.parse(
|
|
10
|
+
readFileSync(new URL('../../package.json', import.meta.url), 'utf8'),
|
|
11
|
+
);
|
|
12
|
+
const packageCapabilities =
|
|
13
|
+
isRecord(packageJson) && isRecord(packageJson.ankh) ? packageJson.ankh.capabilities : undefined;
|
|
13
14
|
|
|
14
15
|
test('publishes valid, uniquely identified canonical ZORA capabilities', () => {
|
|
15
16
|
expect(CAPABILITIES).toHaveLength(2);
|
|
@@ -27,7 +28,10 @@ test('keeps Ankh package metadata identical to the canonical catalog', () => {
|
|
|
27
28
|
});
|
|
28
29
|
|
|
29
30
|
test('exports the catalog from the public capabilities subpath', async () => {
|
|
30
|
-
const capabilities = await import('@ankhorage/zora/capabilities');
|
|
31
|
+
const capabilities: unknown = await import('@ankhorage/zora/capabilities');
|
|
32
|
+
if (!isRecord(capabilities)) {
|
|
33
|
+
throw new Error('Expected a capability module record.');
|
|
34
|
+
}
|
|
31
35
|
|
|
32
36
|
expect(capabilities.CAPABILITIES).toEqual(CAPABILITIES);
|
|
33
37
|
});
|
|
@@ -9,6 +9,11 @@ import { withZoraThemeScope } from '../../../theme/adapters/inbound/withZoraThem
|
|
|
9
9
|
import { useZoraTheme } from '../../../theme/composition/useZoraTheme';
|
|
10
10
|
import { Text } from '../../../typography/public';
|
|
11
11
|
import { resolveExplorerSelection } from '../../application/resolveExplorerSelection';
|
|
12
|
+
import {
|
|
13
|
+
type ExplorerPageRequestSignature,
|
|
14
|
+
shouldDispatchExplorerPageRequest,
|
|
15
|
+
} from '../../application/shouldDispatchExplorerPageRequest';
|
|
16
|
+
import { shouldRequestExplorerPage } from '../../application/shouldRequestExplorerPage';
|
|
12
17
|
import { ExplorerKeyboardProxy } from './ExplorerKeyboardProxy';
|
|
13
18
|
import { useExplorerKeyboardFocus } from './useExplorerKeyboardFocus';
|
|
14
19
|
|
|
@@ -28,6 +33,14 @@ function ExplorerInner({
|
|
|
28
33
|
tileSize = 120,
|
|
29
34
|
zoom = 1,
|
|
30
35
|
loading = false,
|
|
36
|
+
hasMore = false,
|
|
37
|
+
loadingMore = false,
|
|
38
|
+
pagingCollectionId,
|
|
39
|
+
pagingRetryToken,
|
|
40
|
+
onLoadMore,
|
|
41
|
+
permissionStatus = 'granted',
|
|
42
|
+
permissionText,
|
|
43
|
+
onRequestPermission,
|
|
31
44
|
errorText,
|
|
32
45
|
emptyText = 'No items',
|
|
33
46
|
disabled = false,
|
|
@@ -41,6 +54,7 @@ function ExplorerInner({
|
|
|
41
54
|
const anchorId = React.useRef<string | null>(null);
|
|
42
55
|
const [columns, setColumns] = React.useState(1);
|
|
43
56
|
const [visibleIds, setVisibleIds] = React.useState<readonly string[]>([]);
|
|
57
|
+
const lastPageRequestRef = React.useRef<ExplorerPageRequestSignature | null>(null);
|
|
44
58
|
const effectiveSelectedIds = selectedIds ?? internalSelectedIds;
|
|
45
59
|
const itemLookup = React.useMemo(() => new Map(items.map((item) => [item.id, item])), [items]);
|
|
46
60
|
const ids = React.useMemo(
|
|
@@ -50,6 +64,43 @@ function ExplorerInner({
|
|
|
50
64
|
const selected = new Set(effectiveSelectedIds);
|
|
51
65
|
const passive = interactionPolicy === 'passive' || disabled || readOnly;
|
|
52
66
|
|
|
67
|
+
React.useEffect(() => {
|
|
68
|
+
const request = {
|
|
69
|
+
collectionId: pagingCollectionId,
|
|
70
|
+
loadedItemCount: items.length,
|
|
71
|
+
retryToken: pagingRetryToken,
|
|
72
|
+
};
|
|
73
|
+
if (
|
|
74
|
+
!onLoadMore ||
|
|
75
|
+
!shouldDispatchExplorerPageRequest(lastPageRequestRef.current, request) ||
|
|
76
|
+
!shouldRequestExplorerPage(
|
|
77
|
+
ids,
|
|
78
|
+
visibleIds,
|
|
79
|
+
hasMore,
|
|
80
|
+
loadingMore,
|
|
81
|
+
loading,
|
|
82
|
+
errorText,
|
|
83
|
+
permissionStatus,
|
|
84
|
+
)
|
|
85
|
+
) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
lastPageRequestRef.current = request;
|
|
89
|
+
onLoadMore({ loadedItemCount: items.length });
|
|
90
|
+
}, [
|
|
91
|
+
errorText,
|
|
92
|
+
hasMore,
|
|
93
|
+
ids,
|
|
94
|
+
items.length,
|
|
95
|
+
loading,
|
|
96
|
+
loadingMore,
|
|
97
|
+
onLoadMore,
|
|
98
|
+
pagingCollectionId,
|
|
99
|
+
pagingRetryToken,
|
|
100
|
+
permissionStatus,
|
|
101
|
+
visibleIds,
|
|
102
|
+
]);
|
|
103
|
+
|
|
53
104
|
const select = (item: ExplorerItem, intent: 'replace' | 'toggle' | 'range') => {
|
|
54
105
|
if (passive || item.disabled) return;
|
|
55
106
|
const next = resolveExplorerSelection(
|
|
@@ -138,24 +189,42 @@ function ExplorerInner({
|
|
|
138
189
|
);
|
|
139
190
|
};
|
|
140
191
|
|
|
192
|
+
if (permissionStatus === 'denied' || permissionStatus === 'unavailable') {
|
|
193
|
+
return (
|
|
194
|
+
<NativeView testID={testID}>
|
|
195
|
+
<Text>{permissionText ?? 'This collection is unavailable without permission.'}</Text>
|
|
196
|
+
{permissionStatus === 'denied' && onRequestPermission ? (
|
|
197
|
+
<NativePressable accessibilityRole="button" onPress={onRequestPermission}>
|
|
198
|
+
<Text>Grant access</Text>
|
|
199
|
+
</NativePressable>
|
|
200
|
+
) : null}
|
|
201
|
+
</NativeView>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
141
204
|
if (errorText) return <Text testID={testID}>{errorText}</Text>;
|
|
142
205
|
if (loading) return <Text testID={testID}>Loading…</Text>;
|
|
143
206
|
if (items.length === 0) return <Text testID={testID}>{emptyText}</Text>;
|
|
144
207
|
|
|
145
208
|
return (
|
|
146
|
-
<
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
209
|
+
<NativeView>
|
|
210
|
+
{permissionStatus === 'limited' ? (
|
|
211
|
+
<Text>{permissionText ?? 'Showing the media you have allowed.'}</Text>
|
|
212
|
+
) : null}
|
|
213
|
+
<TileGrid
|
|
214
|
+
focusedItemId={keyboard.focusedId ?? undefined}
|
|
215
|
+
height={height}
|
|
216
|
+
interactionPolicy={interactionPolicy}
|
|
217
|
+
onColumnsChange={setColumns}
|
|
218
|
+
onVisibleItemIdsChange={setVisibleIds}
|
|
219
|
+
items={items}
|
|
220
|
+
renderItem={renderTile}
|
|
221
|
+
testID={testID}
|
|
222
|
+
tileSize={tileSize}
|
|
223
|
+
width={width}
|
|
224
|
+
zoom={zoom}
|
|
225
|
+
/>
|
|
226
|
+
{loadingMore ? <Text>Loading more…</Text> : null}
|
|
227
|
+
</NativeView>
|
|
159
228
|
);
|
|
160
229
|
}
|
|
161
230
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type ExplorerPageRequestSignature,
|
|
5
|
+
shouldDispatchExplorerPageRequest,
|
|
6
|
+
} from './shouldDispatchExplorerPageRequest';
|
|
7
|
+
|
|
8
|
+
describe('Explorer page request lifecycle', () => {
|
|
9
|
+
const initialRequest: ExplorerPageRequestSignature = {
|
|
10
|
+
collectionId: 'recent-media',
|
|
11
|
+
loadedItemCount: 60,
|
|
12
|
+
retryToken: undefined,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
test('deduplicates a settled or failed request until the provider explicitly retries it', () => {
|
|
16
|
+
expect(shouldDispatchExplorerPageRequest(null, initialRequest)).toBe(true);
|
|
17
|
+
expect(shouldDispatchExplorerPageRequest(initialRequest, initialRequest)).toBe(false);
|
|
18
|
+
expect(
|
|
19
|
+
shouldDispatchExplorerPageRequest(initialRequest, {
|
|
20
|
+
...initialRequest,
|
|
21
|
+
retryToken: 'retry-1',
|
|
22
|
+
}),
|
|
23
|
+
).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('resets paging for a replacement collection with the same loaded row count', () => {
|
|
27
|
+
expect(
|
|
28
|
+
shouldDispatchExplorerPageRequest(initialRequest, {
|
|
29
|
+
...initialRequest,
|
|
30
|
+
collectionId: 'search-results',
|
|
31
|
+
}),
|
|
32
|
+
).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('allows the next normal page when the provider extends the collection', () => {
|
|
36
|
+
expect(
|
|
37
|
+
shouldDispatchExplorerPageRequest(initialRequest, {
|
|
38
|
+
...initialRequest,
|
|
39
|
+
loadedItemCount: 120,
|
|
40
|
+
}),
|
|
41
|
+
).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface ExplorerPageRequestSignature {
|
|
2
|
+
readonly collectionId: string | undefined;
|
|
3
|
+
readonly loadedItemCount: number;
|
|
4
|
+
readonly retryToken: string | undefined;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/*** Preserves one request per provider page boundary until its collection, row count, or retry token changes. */
|
|
8
|
+
export function shouldDispatchExplorerPageRequest(
|
|
9
|
+
previous: ExplorerPageRequestSignature | null,
|
|
10
|
+
next: ExplorerPageRequestSignature,
|
|
11
|
+
): boolean {
|
|
12
|
+
if (previous === null) return true;
|
|
13
|
+
return (
|
|
14
|
+
previous.collectionId !== next.collectionId ||
|
|
15
|
+
previous.loadedItemCount !== next.loadedItemCount ||
|
|
16
|
+
previous.retryToken !== next.retryToken
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { shouldRequestExplorerPage } from './shouldRequestExplorerPage';
|
|
4
|
+
|
|
5
|
+
describe('Explorer paging boundary', () => {
|
|
6
|
+
const ids = Array.from({ length: 100 }, (_, index) => `asset-${index}`);
|
|
7
|
+
const available = ['granted', 'limited'] as const;
|
|
8
|
+
|
|
9
|
+
test('requests a page only when the visible virtual window reaches the loaded end', () => {
|
|
10
|
+
expect(
|
|
11
|
+
shouldRequestExplorerPage(
|
|
12
|
+
ids,
|
|
13
|
+
['asset-50', 'asset-51'],
|
|
14
|
+
true,
|
|
15
|
+
false,
|
|
16
|
+
false,
|
|
17
|
+
undefined,
|
|
18
|
+
'granted',
|
|
19
|
+
),
|
|
20
|
+
).toBe(false);
|
|
21
|
+
expect(
|
|
22
|
+
shouldRequestExplorerPage(
|
|
23
|
+
ids,
|
|
24
|
+
['asset-88', 'asset-89'],
|
|
25
|
+
true,
|
|
26
|
+
false,
|
|
27
|
+
false,
|
|
28
|
+
undefined,
|
|
29
|
+
'granted',
|
|
30
|
+
),
|
|
31
|
+
).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('does not request while the provider has no page or is already loading one', () => {
|
|
35
|
+
expect(
|
|
36
|
+
shouldRequestExplorerPage(ids, ['asset-99'], false, false, false, undefined, 'granted'),
|
|
37
|
+
).toBe(false);
|
|
38
|
+
expect(
|
|
39
|
+
shouldRequestExplorerPage(ids, ['asset-99'], true, true, false, undefined, 'granted'),
|
|
40
|
+
).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('gates paging until the collection is accessible, loaded, and error-free', () => {
|
|
44
|
+
for (const permissionStatus of ['denied', 'unavailable'] as const) {
|
|
45
|
+
expect(
|
|
46
|
+
shouldRequestExplorerPage(
|
|
47
|
+
ids,
|
|
48
|
+
['asset-99'],
|
|
49
|
+
true,
|
|
50
|
+
false,
|
|
51
|
+
false,
|
|
52
|
+
undefined,
|
|
53
|
+
permissionStatus,
|
|
54
|
+
),
|
|
55
|
+
).toBe(false);
|
|
56
|
+
}
|
|
57
|
+
for (const permissionStatus of available) {
|
|
58
|
+
expect(
|
|
59
|
+
shouldRequestExplorerPage(
|
|
60
|
+
ids,
|
|
61
|
+
['asset-99'],
|
|
62
|
+
true,
|
|
63
|
+
false,
|
|
64
|
+
true,
|
|
65
|
+
undefined,
|
|
66
|
+
permissionStatus,
|
|
67
|
+
),
|
|
68
|
+
).toBe(false);
|
|
69
|
+
expect(
|
|
70
|
+
shouldRequestExplorerPage(
|
|
71
|
+
ids,
|
|
72
|
+
['asset-99'],
|
|
73
|
+
true,
|
|
74
|
+
false,
|
|
75
|
+
false,
|
|
76
|
+
'Failed to load',
|
|
77
|
+
permissionStatus,
|
|
78
|
+
),
|
|
79
|
+
).toBe(false);
|
|
80
|
+
expect(
|
|
81
|
+
shouldRequestExplorerPage(
|
|
82
|
+
ids,
|
|
83
|
+
['asset-99'],
|
|
84
|
+
true,
|
|
85
|
+
false,
|
|
86
|
+
false,
|
|
87
|
+
undefined,
|
|
88
|
+
permissionStatus,
|
|
89
|
+
),
|
|
90
|
+
).toBe(true);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ExplorerPermissionStatus } from '../../../types/explorer';
|
|
2
|
+
|
|
3
|
+
/*** Determines whether the mounted, accessible viewport has reached the provider-owned page boundary. */
|
|
4
|
+
export function shouldRequestExplorerPage(
|
|
5
|
+
itemIds: readonly string[],
|
|
6
|
+
visibleIds: readonly string[],
|
|
7
|
+
hasMore: boolean,
|
|
8
|
+
loadingMore: boolean,
|
|
9
|
+
loading: boolean,
|
|
10
|
+
errorText: string | undefined,
|
|
11
|
+
permissionStatus: ExplorerPermissionStatus,
|
|
12
|
+
threshold = 12,
|
|
13
|
+
): boolean {
|
|
14
|
+
if (
|
|
15
|
+
!hasMore ||
|
|
16
|
+
loadingMore ||
|
|
17
|
+
loading ||
|
|
18
|
+
errorText !== undefined ||
|
|
19
|
+
(permissionStatus !== 'granted' && permissionStatus !== 'limited') ||
|
|
20
|
+
visibleIds.length === 0
|
|
21
|
+
) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
const firstPagingIndex = Math.max(0, itemIds.length - threshold);
|
|
25
|
+
return visibleIds.some((id) => itemIds.indexOf(id) >= firstPagingIndex);
|
|
26
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { fileExplorerMeta, mediaExplorerMeta } from './meta';
|
|
4
|
+
|
|
5
|
+
describe('Explorer authoring metadata', () => {
|
|
6
|
+
const authorableProps = [
|
|
7
|
+
'items',
|
|
8
|
+
'selectionMode',
|
|
9
|
+
'selectedIds',
|
|
10
|
+
'defaultSelectedIds',
|
|
11
|
+
'width',
|
|
12
|
+
'height',
|
|
13
|
+
'tileSize',
|
|
14
|
+
'zoom',
|
|
15
|
+
'loading',
|
|
16
|
+
'hasMore',
|
|
17
|
+
'loadingMore',
|
|
18
|
+
'pagingCollectionId',
|
|
19
|
+
'pagingRetryToken',
|
|
20
|
+
'permissionStatus',
|
|
21
|
+
'permissionText',
|
|
22
|
+
'errorText',
|
|
23
|
+
'emptyText',
|
|
24
|
+
'disabled',
|
|
25
|
+
'readOnly',
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
test('keeps MediaExplorer and FileExplorer serializable props in parity', () => {
|
|
29
|
+
for (const meta of [mediaExplorerMeta, fileExplorerMeta]) {
|
|
30
|
+
expect(meta.directManifestNode).toBe(true);
|
|
31
|
+
for (const prop of authorableProps) expect(meta.props).toHaveProperty(prop);
|
|
32
|
+
expect(meta.props.pagingCollectionId).toMatchObject({ type: 'string', category: 'Paging' });
|
|
33
|
+
expect(meta.props.pagingRetryToken).toMatchObject({ type: 'string', category: 'Paging' });
|
|
34
|
+
expect(meta.props.permissionStatus).toMatchObject({
|
|
35
|
+
type: 'enum',
|
|
36
|
+
enum: ['granted', 'limited', 'denied', 'unavailable'],
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('normalizes paging and permission actions without exposing provider objects', () => {
|
|
42
|
+
for (const meta of [mediaExplorerMeta, fileExplorerMeta]) {
|
|
43
|
+
expect(meta.events.loadMore).toEqual({
|
|
44
|
+
label: 'Load more',
|
|
45
|
+
eventType: 'explorer.loadMore',
|
|
46
|
+
payloadFields: [{ path: 'loadedItemCount', type: 'number' }],
|
|
47
|
+
});
|
|
48
|
+
expect(meta.events.requestPermission).toEqual({
|
|
49
|
+
label: 'Request permission',
|
|
50
|
+
eventType: 'explorer.requestPermission',
|
|
51
|
+
payloadFields: [],
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -30,16 +30,31 @@ const explorerProps = {
|
|
|
30
30
|
default: 'single',
|
|
31
31
|
},
|
|
32
32
|
selectedIds: { type: 'array', category: 'Selection' },
|
|
33
|
+
defaultSelectedIds: { type: 'array', category: 'Selection' },
|
|
34
|
+
width: { type: 'number', category: 'Layout' },
|
|
33
35
|
tileSize: { type: 'number', category: 'Layout', default: 120 },
|
|
34
36
|
height: { type: 'number', category: 'Layout', default: 440 },
|
|
35
37
|
zoom: { type: 'number', category: 'Layout', default: 1 },
|
|
36
38
|
loading: { type: 'boolean', category: 'State' },
|
|
39
|
+
hasMore: { type: 'boolean', category: 'Paging' },
|
|
40
|
+
loadingMore: { type: 'boolean', category: 'Paging' },
|
|
41
|
+
pagingCollectionId: { type: 'string', category: 'Paging' },
|
|
42
|
+
pagingRetryToken: { type: 'string', category: 'Paging' },
|
|
43
|
+
permissionStatus: {
|
|
44
|
+
type: 'enum',
|
|
45
|
+
category: 'Permission',
|
|
46
|
+
enum: ['granted', 'limited', 'denied', 'unavailable'],
|
|
47
|
+
default: 'granted',
|
|
48
|
+
},
|
|
49
|
+
permissionText: { type: 'string', category: 'Permission' },
|
|
37
50
|
errorText: { type: 'string', category: 'State' },
|
|
38
51
|
emptyText: { type: 'string', category: 'Content' },
|
|
39
52
|
disabled: { type: 'boolean', category: 'State' },
|
|
40
53
|
readOnly: { type: 'boolean', category: 'State' },
|
|
41
54
|
onSelectionChange: { type: 'action', category: 'Events' },
|
|
42
55
|
onActivate: { type: 'action', category: 'Events' },
|
|
56
|
+
onLoadMore: { type: 'action', category: 'Events' },
|
|
57
|
+
onRequestPermission: { type: 'action', category: 'Events' },
|
|
43
58
|
} as const;
|
|
44
59
|
|
|
45
60
|
const explorerEvents = {
|
|
@@ -53,6 +68,16 @@ const explorerEvents = {
|
|
|
53
68
|
eventType: 'explorer.activate',
|
|
54
69
|
payloadFields: [{ path: 'id', type: 'string' }],
|
|
55
70
|
},
|
|
71
|
+
loadMore: {
|
|
72
|
+
label: 'Load more',
|
|
73
|
+
eventType: 'explorer.loadMore',
|
|
74
|
+
payloadFields: [{ path: 'loadedItemCount', type: 'number' }],
|
|
75
|
+
},
|
|
76
|
+
requestPermission: {
|
|
77
|
+
label: 'Request permission',
|
|
78
|
+
eventType: 'explorer.requestPermission',
|
|
79
|
+
payloadFields: [],
|
|
80
|
+
},
|
|
56
81
|
} as const;
|
|
57
82
|
|
|
58
83
|
/** Framework-bound Explorer is a composition primitive, not a separate manifest shape. */
|
package/src/types/explorer.ts
CHANGED
|
@@ -18,6 +18,9 @@ export interface ExplorerItem {
|
|
|
18
18
|
|
|
19
19
|
export type ExplorerSelectionMode = 'single' | 'multi';
|
|
20
20
|
|
|
21
|
+
/** Provider-owned access state projected into the portable explorer presentation. */
|
|
22
|
+
export type ExplorerPermissionStatus = 'granted' | 'limited' | 'denied' | 'unavailable';
|
|
23
|
+
|
|
21
24
|
export interface ExplorerActivateEvent {
|
|
22
25
|
readonly id: string;
|
|
23
26
|
}
|
|
@@ -26,6 +29,11 @@ export interface ExplorerSelectionChangeEvent {
|
|
|
26
29
|
readonly selectedIds: readonly string[];
|
|
27
30
|
}
|
|
28
31
|
|
|
32
|
+
/** Requests the next provider page without exposing a provider cursor or native object. */
|
|
33
|
+
export interface ExplorerPageRequestEvent {
|
|
34
|
+
readonly loadedItemCount: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
29
37
|
export interface ExplorerProps extends ZoraBaseProps {
|
|
30
38
|
items: readonly ExplorerItem[];
|
|
31
39
|
selectionMode?: ExplorerSelectionMode;
|
|
@@ -38,6 +46,28 @@ export interface ExplorerProps extends ZoraBaseProps {
|
|
|
38
46
|
tileSize?: number;
|
|
39
47
|
zoom?: number;
|
|
40
48
|
loading?: boolean;
|
|
49
|
+
/** Provider paging remains application-owned; Explorer only requests the next page near its end. */
|
|
50
|
+
hasMore?: boolean;
|
|
51
|
+
loadingMore?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Stable serializable identity for a provider collection.
|
|
54
|
+
*
|
|
55
|
+
* Supply this whenever paging is enabled so a replacement collection with the same number of
|
|
56
|
+
* loaded rows receives its own initial page request.
|
|
57
|
+
*/
|
|
58
|
+
pagingCollectionId?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Provider-controlled retry token for the current page boundary.
|
|
61
|
+
*
|
|
62
|
+
* Change this after a failed request to permit one deliberate retry without causing automatic
|
|
63
|
+
* retry loops.
|
|
64
|
+
*/
|
|
65
|
+
pagingRetryToken?: string;
|
|
66
|
+
onLoadMore?: (event: ExplorerPageRequestEvent) => void;
|
|
67
|
+
/** Never pass a native permission object through this presentation boundary. */
|
|
68
|
+
permissionStatus?: ExplorerPermissionStatus;
|
|
69
|
+
permissionText?: string;
|
|
70
|
+
onRequestPermission?: () => void;
|
|
41
71
|
errorText?: string;
|
|
42
72
|
emptyText?: string;
|
|
43
73
|
disabled?: boolean;
|