@ankhorage/zora 25.1.0 → 25.2.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.
- package/CHANGELOG.md +7 -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 +1 -1
- 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
|
@@ -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;
|
|
@@ -49,6 +49,22 @@ function resolveExplorerSelection(orderedIds, currentIds, targetId, anchorId, in
|
|
|
49
49
|
return orderedIds.slice(start, end + 1);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// src/features/explorer/application/shouldDispatchExplorerPageRequest.ts
|
|
53
|
+
function shouldDispatchExplorerPageRequest(previous, next) {
|
|
54
|
+
if (previous === null)
|
|
55
|
+
return true;
|
|
56
|
+
return previous.collectionId !== next.collectionId || previous.loadedItemCount !== next.loadedItemCount || previous.retryToken !== next.retryToken;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// src/features/explorer/application/shouldRequestExplorerPage.ts
|
|
60
|
+
function shouldRequestExplorerPage(itemIds, visibleIds, hasMore, loadingMore, loading, errorText, permissionStatus, threshold = 12) {
|
|
61
|
+
if (!hasMore || loadingMore || loading || errorText !== undefined || permissionStatus !== "granted" && permissionStatus !== "limited" || visibleIds.length === 0) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
const firstPagingIndex = Math.max(0, itemIds.length - threshold);
|
|
65
|
+
return visibleIds.some((id) => itemIds.indexOf(id) >= firstPagingIndex);
|
|
66
|
+
}
|
|
67
|
+
|
|
52
68
|
// src/features/explorer/adapters/inbound/ExplorerKeyboardProxy.web.tsx
|
|
53
69
|
import { jsx } from "react/jsx-runtime";
|
|
54
70
|
function ExplorerKeyboardProxy({ children, onKeyDown }) {
|
|
@@ -169,6 +185,14 @@ function ExplorerInner({
|
|
|
169
185
|
tileSize = 120,
|
|
170
186
|
zoom = 1,
|
|
171
187
|
loading = false,
|
|
188
|
+
hasMore = false,
|
|
189
|
+
loadingMore = false,
|
|
190
|
+
pagingCollectionId,
|
|
191
|
+
pagingRetryToken,
|
|
192
|
+
onLoadMore,
|
|
193
|
+
permissionStatus = "granted",
|
|
194
|
+
permissionText,
|
|
195
|
+
onRequestPermission,
|
|
172
196
|
errorText,
|
|
173
197
|
emptyText = "No items",
|
|
174
198
|
disabled = false,
|
|
@@ -181,11 +205,36 @@ function ExplorerInner({
|
|
|
181
205
|
const anchorId = React2.useRef(null);
|
|
182
206
|
const [columns, setColumns] = React2.useState(1);
|
|
183
207
|
const [visibleIds, setVisibleIds] = React2.useState([]);
|
|
208
|
+
const lastPageRequestRef = React2.useRef(null);
|
|
184
209
|
const effectiveSelectedIds = selectedIds ?? internalSelectedIds;
|
|
185
210
|
const itemLookup = React2.useMemo(() => new Map(items.map((item) => [item.id, item])), [items]);
|
|
186
211
|
const ids = React2.useMemo(() => items.filter((item) => !item.disabled).map((item) => item.id), [items]);
|
|
187
212
|
const selected = new Set(effectiveSelectedIds);
|
|
188
213
|
const passive = interactionPolicy === "passive" || disabled || readOnly;
|
|
214
|
+
React2.useEffect(() => {
|
|
215
|
+
const request = {
|
|
216
|
+
collectionId: pagingCollectionId,
|
|
217
|
+
loadedItemCount: items.length,
|
|
218
|
+
retryToken: pagingRetryToken
|
|
219
|
+
};
|
|
220
|
+
if (!onLoadMore || !shouldDispatchExplorerPageRequest(lastPageRequestRef.current, request) || !shouldRequestExplorerPage(ids, visibleIds, hasMore, loadingMore, loading, errorText, permissionStatus)) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
lastPageRequestRef.current = request;
|
|
224
|
+
onLoadMore({ loadedItemCount: items.length });
|
|
225
|
+
}, [
|
|
226
|
+
errorText,
|
|
227
|
+
hasMore,
|
|
228
|
+
ids,
|
|
229
|
+
items.length,
|
|
230
|
+
loading,
|
|
231
|
+
loadingMore,
|
|
232
|
+
onLoadMore,
|
|
233
|
+
pagingCollectionId,
|
|
234
|
+
pagingRetryToken,
|
|
235
|
+
permissionStatus,
|
|
236
|
+
visibleIds
|
|
237
|
+
]);
|
|
189
238
|
const select = (item, intent) => {
|
|
190
239
|
if (passive || item.disabled)
|
|
191
240
|
return;
|
|
@@ -261,6 +310,23 @@ function ExplorerInner({
|
|
|
261
310
|
})
|
|
262
311
|
});
|
|
263
312
|
};
|
|
313
|
+
if (permissionStatus === "denied" || permissionStatus === "unavailable") {
|
|
314
|
+
return /* @__PURE__ */ jsxs(import_react_native.View, {
|
|
315
|
+
testID,
|
|
316
|
+
children: [
|
|
317
|
+
/* @__PURE__ */ jsx2(Text3, {
|
|
318
|
+
children: permissionText ?? "This collection is unavailable without permission."
|
|
319
|
+
}),
|
|
320
|
+
permissionStatus === "denied" && onRequestPermission ? /* @__PURE__ */ jsx2(import_react_native.Pressable, {
|
|
321
|
+
accessibilityRole: "button",
|
|
322
|
+
onPress: onRequestPermission,
|
|
323
|
+
children: /* @__PURE__ */ jsx2(Text3, {
|
|
324
|
+
children: "Grant access"
|
|
325
|
+
})
|
|
326
|
+
}) : null
|
|
327
|
+
]
|
|
328
|
+
});
|
|
329
|
+
}
|
|
264
330
|
if (errorText)
|
|
265
331
|
return /* @__PURE__ */ jsx2(Text3, {
|
|
266
332
|
testID,
|
|
@@ -276,18 +342,28 @@ function ExplorerInner({
|
|
|
276
342
|
testID,
|
|
277
343
|
children: emptyText
|
|
278
344
|
});
|
|
279
|
-
return /* @__PURE__ */
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
345
|
+
return /* @__PURE__ */ jsxs(import_react_native.View, {
|
|
346
|
+
children: [
|
|
347
|
+
permissionStatus === "limited" ? /* @__PURE__ */ jsx2(Text3, {
|
|
348
|
+
children: permissionText ?? "Showing the media you have allowed."
|
|
349
|
+
}) : null,
|
|
350
|
+
/* @__PURE__ */ jsx2(TileGrid2, {
|
|
351
|
+
focusedItemId: keyboard.focusedId ?? undefined,
|
|
352
|
+
height,
|
|
353
|
+
interactionPolicy,
|
|
354
|
+
onColumnsChange: setColumns,
|
|
355
|
+
onVisibleItemIdsChange: setVisibleIds,
|
|
356
|
+
items,
|
|
357
|
+
renderItem: renderTile,
|
|
358
|
+
testID,
|
|
359
|
+
tileSize,
|
|
360
|
+
width,
|
|
361
|
+
zoom
|
|
362
|
+
}),
|
|
363
|
+
loadingMore ? /* @__PURE__ */ jsx2(Text3, {
|
|
364
|
+
children: "Loading more…"
|
|
365
|
+
}) : null
|
|
366
|
+
]
|
|
291
367
|
});
|
|
292
368
|
}
|
|
293
369
|
function resolveExplorerPressIntent(event) {
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
Explorer2,
|
|
15
15
|
FileExplorer2,
|
|
16
16
|
MediaExplorer2
|
|
17
|
-
} from "../../chunks/Accordion-
|
|
17
|
+
} from "../../chunks/Accordion-kkr7xfh2.js";
|
|
18
18
|
import"../../chunks/Accordion-bff0p3x4.js";
|
|
19
19
|
import"../../chunks/Accordion-srjya6dz.js";
|
|
20
20
|
import"../../chunks/Accordion-184r2r82.js";
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
Explorer2,
|
|
15
15
|
FileExplorer2,
|
|
16
16
|
MediaExplorer2
|
|
17
|
-
} from "../../chunks/Accordion-
|
|
17
|
+
} from "../../chunks/Accordion-kkr7xfh2.js";
|
|
18
18
|
import"../../chunks/Accordion-bff0p3x4.js";
|
|
19
19
|
import"../../chunks/Accordion-srjya6dz.js";
|
|
20
20
|
import"../../chunks/Accordion-184r2r82.js";
|