@ankhorage/zora 25.0.1 → 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.
Files changed (86) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +2 -2
  3. package/dist/features/explorer/adapters/inbound/Explorer.d.ts.map +1 -1
  4. package/dist/features/explorer/adapters/inbound/Explorer.js +72 -14
  5. package/dist/features/explorer/adapters/inbound/Explorer.js.map +1 -1
  6. package/dist/features/explorer/adapters/inbound/ExplorerKeyboardProxy.d.ts +9 -0
  7. package/dist/features/explorer/adapters/inbound/ExplorerKeyboardProxy.d.ts.map +1 -0
  8. package/dist/features/explorer/adapters/inbound/ExplorerKeyboardProxy.js +5 -0
  9. package/dist/features/explorer/adapters/inbound/ExplorerKeyboardProxy.js.map +1 -0
  10. package/dist/features/explorer/adapters/inbound/ExplorerKeyboardProxy.native.d.ts +9 -0
  11. package/dist/features/explorer/adapters/inbound/ExplorerKeyboardProxy.native.d.ts.map +1 -0
  12. package/dist/features/explorer/adapters/inbound/ExplorerKeyboardProxy.native.js +5 -0
  13. package/dist/features/explorer/adapters/inbound/ExplorerKeyboardProxy.native.js.map +1 -0
  14. package/dist/features/explorer/adapters/inbound/ExplorerKeyboardProxy.web.d.ts +9 -0
  15. package/dist/features/explorer/adapters/inbound/ExplorerKeyboardProxy.web.d.ts.map +1 -0
  16. package/dist/features/explorer/adapters/inbound/ExplorerKeyboardProxy.web.js +10 -0
  17. package/dist/features/explorer/adapters/inbound/ExplorerKeyboardProxy.web.js.map +1 -0
  18. package/dist/features/explorer/adapters/inbound/useExplorerKeyboardFocus.d.ts +14 -0
  19. package/dist/features/explorer/adapters/inbound/useExplorerKeyboardFocus.d.ts.map +1 -0
  20. package/dist/features/explorer/adapters/inbound/useExplorerKeyboardFocus.js +54 -0
  21. package/dist/features/explorer/adapters/inbound/useExplorerKeyboardFocus.js.map +1 -0
  22. package/dist/features/explorer/application/getExplorerNextFocusId.d.ts +6 -0
  23. package/dist/features/explorer/application/getExplorerNextFocusId.d.ts.map +1 -0
  24. package/dist/features/explorer/application/getExplorerNextFocusId.js +53 -0
  25. package/dist/features/explorer/application/getExplorerNextFocusId.js.map +1 -0
  26. package/dist/features/explorer/application/shouldDispatchExplorerPageRequest.d.ts +8 -0
  27. package/dist/features/explorer/application/shouldDispatchExplorerPageRequest.d.ts.map +1 -0
  28. package/dist/features/explorer/application/shouldDispatchExplorerPageRequest.js +9 -0
  29. package/dist/features/explorer/application/shouldDispatchExplorerPageRequest.js.map +1 -0
  30. package/dist/features/explorer/application/shouldRequestExplorerPage.d.ts +4 -0
  31. package/dist/features/explorer/application/shouldRequestExplorerPage.d.ts.map +1 -0
  32. package/dist/features/explorer/application/shouldRequestExplorerPage.js +14 -0
  33. package/dist/features/explorer/application/shouldRequestExplorerPage.js.map +1 -0
  34. package/dist/features/explorer/meta.d.ts +110 -0
  35. package/dist/features/explorer/meta.d.ts.map +1 -1
  36. package/dist/features/explorer/meta.js +25 -0
  37. package/dist/features/explorer/meta.js.map +1 -1
  38. package/dist/features/explorer/public.d.ts +2 -1
  39. package/dist/features/explorer/public.d.ts.map +1 -1
  40. package/dist/features/explorer/public.js +1 -0
  41. package/dist/features/explorer/public.js.map +1 -1
  42. package/dist/features/grid-view/adapters/inbound/GridView.d.ts +1 -1
  43. package/dist/features/grid-view/adapters/inbound/GridView.d.ts.map +1 -1
  44. package/dist/features/grid-view/adapters/inbound/GridView.js +36 -4
  45. package/dist/features/grid-view/adapters/inbound/GridView.js.map +1 -1
  46. package/dist/features/grid-view/adapters/inbound/TileGrid.d.ts +1 -1
  47. package/dist/features/grid-view/adapters/inbound/TileGrid.d.ts.map +1 -1
  48. package/dist/features/grid-view/adapters/inbound/TileGrid.js +7 -3
  49. package/dist/features/grid-view/adapters/inbound/TileGrid.js.map +1 -1
  50. package/dist/types/explorer.d.ts +28 -0
  51. package/dist/types/explorer.d.ts.map +1 -1
  52. package/dist/types/explorer.js.map +1 -1
  53. package/dist/types/grid-view.d.ts +10 -0
  54. package/dist/types/grid-view.d.ts.map +1 -1
  55. package/dist/types/grid-view.js.map +1 -1
  56. package/package.json +1 -1
  57. package/src/capabilities/index.test.ts +10 -6
  58. package/src/features/explorer/adapters/inbound/Explorer.tsx +158 -53
  59. package/src/features/explorer/adapters/inbound/ExplorerKeyboardProxy.native.tsx +11 -0
  60. package/src/features/explorer/adapters/inbound/ExplorerKeyboardProxy.tsx +11 -0
  61. package/src/features/explorer/adapters/inbound/ExplorerKeyboardProxy.web.tsx +20 -0
  62. package/src/features/explorer/adapters/inbound/useExplorerKeyboardFocus.test.tsx +232 -0
  63. package/src/features/explorer/adapters/inbound/useExplorerKeyboardFocus.ts +77 -0
  64. package/src/features/explorer/application/getExplorerNextFocusId.test.ts +37 -0
  65. package/src/features/explorer/application/getExplorerNextFocusId.ts +61 -0
  66. package/src/features/explorer/application/shouldDispatchExplorerPageRequest.test.ts +43 -0
  67. package/src/features/explorer/application/shouldDispatchExplorerPageRequest.ts +18 -0
  68. package/src/features/explorer/application/shouldRequestExplorerPage.test.ts +93 -0
  69. package/src/features/explorer/application/shouldRequestExplorerPage.ts +26 -0
  70. package/src/features/explorer/meta.test.ts +55 -0
  71. package/src/features/explorer/meta.ts +25 -0
  72. package/src/features/explorer/public.ts +3 -0
  73. package/src/features/grid-view/adapters/inbound/GridView.tsx +49 -1
  74. package/src/features/grid-view/adapters/inbound/TileGrid.tsx +11 -0
  75. package/src/types/explorer.ts +30 -0
  76. package/src/types/grid-view.ts +10 -0
  77. package/web-dist/chunks/{Accordion-b20zfsfz.js → Accordion-54k0ncs3.js} +83 -0
  78. package/web-dist/chunks/Accordion-kkr7xfh2.js +397 -0
  79. package/web-dist/components/explorer/Explorer.js +2 -2
  80. package/web-dist/components/file-explorer/FileExplorer.js +2 -2
  81. package/web-dist/components/grid-view/GridView.js +1 -1
  82. package/web-dist/components/media-explorer/MediaExplorer.js +2 -2
  83. package/web-dist/components/tile-grid/TileGrid.js +1 -1
  84. package/web-dist/components/uploader/Uploader.js +2 -2
  85. package/web-dist/manifest.json +10 -10
  86. package/web-dist/chunks/Accordion-0md8wbtx.js +0 -197
@@ -0,0 +1,37 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+
3
+ import { getExplorerNextFocusId } from './getExplorerNextFocusId';
4
+
5
+ describe('Explorer virtualized keyboard focus', () => {
6
+ const ids = ['a', 'b', 'c', 'd', 'e', 'f', 'g'];
7
+
8
+ test('moves by tile and by responsive row while staying within the catalogue', () => {
9
+ expect(getExplorerNextFocusId(ids, 'b', 'ArrowRight', 3)).toBe('c');
10
+ expect(getExplorerNextFocusId(ids, 'b', 'ArrowDown', 3)).toBe('e');
11
+ expect(getExplorerNextFocusId(ids, 'b', 'ArrowUp', 3)).toBe('a');
12
+ expect(getExplorerNextFocusId(ids, 'g', 'ArrowDown', 3)).toBe('g');
13
+ });
14
+
15
+ test('uses full visual positions and skips disabled items without collapsing rows', () => {
16
+ const disabled = new Set(['d', 'a']);
17
+ expect(getExplorerNextFocusId(ids, 'b', 'ArrowDown', 3, disabled)).toBe('e');
18
+ expect(getExplorerNextFocusId(ids, 'b', 'Home', 3, disabled)).toBe('b');
19
+ expect(getExplorerNextFocusId(ids, 'f', 'ArrowLeft', 3, new Set(['e']))).toBe('d');
20
+ expect(getExplorerNextFocusId(ids, 'g', 'End', 3, new Set(['g']))).toBe('f');
21
+ expect(getExplorerNextFocusId(ids, 'b', 'Home', 3, new Set(ids))).toBeNull();
22
+ });
23
+
24
+ test('navigates across ten thousand virtual positions without materializing DOM cells', () => {
25
+ const large = Array.from({ length: 10000 }, (_, index) => `tile-${index}`);
26
+ expect(getExplorerNextFocusId(large, 'tile-0', 'End', 4)).toBe('tile-9999');
27
+ expect(getExplorerNextFocusId(large, 'tile-9999', 'Home', 4)).toBe('tile-0');
28
+ expect(getExplorerNextFocusId(large, 'tile-2', 'ArrowDown', 4)).toBe('tile-6');
29
+ expect(getExplorerNextFocusId(large, 'tile-2', 'ArrowDown', 2)).toBe('tile-4');
30
+ });
31
+
32
+ test('supports first and last collection navigation without creating placeholder cells', () => {
33
+ expect(getExplorerNextFocusId(ids, 'd', 'Home', 3)).toBe('a');
34
+ expect(getExplorerNextFocusId(ids, 'd', 'End', 3)).toBe('g');
35
+ expect(getExplorerNextFocusId(ids, 'd', 'Enter', 3)).toBeNull();
36
+ });
37
+ });
@@ -0,0 +1,61 @@
1
+ /***
2
+ * Resolve focus along actual tile positions while skipping unavailable items
3
+ * without collapsing the visual row geometry.
4
+ */
5
+ export function getExplorerNextFocusId(
6
+ ids: readonly string[],
7
+ currentId: string | null,
8
+ key: string,
9
+ columns: number,
10
+ disabledIds: ReadonlySet<string> = new Set(),
11
+ ): string | null {
12
+ const delta = resolveFocusDelta(key, columns);
13
+ if (delta === null || ids.length === 0) return null;
14
+
15
+ const firstIndex = ids.findIndex((id) => !disabledIds.has(id));
16
+ if (firstIndex < 0) return null;
17
+
18
+ const currentIndex = Math.max(firstIndex, ids.indexOf(currentId ?? ''));
19
+ const candidateIndex =
20
+ delta === Number.NEGATIVE_INFINITY
21
+ ? 0
22
+ : delta === Number.POSITIVE_INFINITY
23
+ ? ids.length - 1
24
+ : Math.max(0, Math.min(ids.length - 1, currentIndex + delta));
25
+ const direction =
26
+ delta === Number.NEGATIVE_INFINITY
27
+ ? 1
28
+ : delta === Number.POSITIVE_INFINITY
29
+ ? -1
30
+ : Math.sign(delta);
31
+
32
+ for (let index = candidateIndex; index >= 0 && index < ids.length; index += direction) {
33
+ const candidate = ids.at(index);
34
+ if (candidate !== undefined && !disabledIds.has(candidate)) return candidate;
35
+ }
36
+ const current = ids.at(currentIndex);
37
+ return current !== undefined && !disabledIds.has(current)
38
+ ? current
39
+ : (ids.at(firstIndex) ?? null);
40
+ }
41
+
42
+ /*** Translate navigation keys into collection-relative movements. */
43
+ function resolveFocusDelta(key: string, columns: number): number | null {
44
+ const rowSize = Math.max(1, Math.floor(columns));
45
+ switch (key) {
46
+ case 'ArrowLeft':
47
+ return -1;
48
+ case 'ArrowRight':
49
+ return 1;
50
+ case 'ArrowUp':
51
+ return -rowSize;
52
+ case 'ArrowDown':
53
+ return rowSize;
54
+ case 'Home':
55
+ return Number.NEGATIVE_INFINITY;
56
+ case 'End':
57
+ return Number.POSITIVE_INFINITY;
58
+ default:
59
+ return null;
60
+ }
61
+ }
@@ -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. */
@@ -2,6 +2,8 @@ export type {
2
2
  ExplorerActivateEvent,
3
3
  ExplorerItem,
4
4
  ExplorerItemKind,
5
+ ExplorerPageRequestEvent,
6
+ ExplorerPermissionStatus,
5
7
  ExplorerProps,
6
8
  ExplorerSelectionChangeEvent,
7
9
  ExplorerSelectionMode,
@@ -9,4 +11,5 @@ export type {
9
11
  export { Explorer } from './adapters/inbound/Explorer';
10
12
  export { FileExplorer } from './adapters/inbound/FileExplorer';
11
13
  export { MediaExplorer } from './adapters/inbound/MediaExplorer';
14
+ export { getExplorerNextFocusId } from './application/getExplorerNextFocusId';
12
15
  export { resolveExplorerSelection } from './application/resolveExplorerSelection';
@@ -1,4 +1,9 @@
1
- import { getVisibleGridItems, type GridViewport, worldToViewport } from '@ankhorage/grid-view';
1
+ import {
2
+ getVisibleGridItems,
3
+ type GridViewport,
4
+ revealWorldRect,
5
+ worldToViewport,
6
+ } from '@ankhorage/grid-view';
2
7
  import React from 'react';
3
8
  import { ScrollView as NativeScrollView, View as NativeView } from 'react-native';
4
9
 
@@ -18,13 +23,18 @@ export function GridView({
18
23
  height,
19
24
  zoom = 1,
20
25
  overscanPixels = 160,
26
+ focusedItemId,
27
+ revealPaddingPixels = 8,
21
28
  interactionPolicy,
22
29
  onViewportChange,
30
+ onVisibleItemIdsChange,
23
31
  renderItem,
24
32
  testID,
25
33
  }: GridViewProps) {
26
34
  const [scrollX, setScrollX] = React.useState(0);
27
35
  const [scrollY, setScrollY] = React.useState(0);
36
+ const horizontalScrollRef = React.useRef<NativeScrollView>(null);
37
+ const verticalScrollRef = React.useRef<NativeScrollView>(null);
28
38
  const scale = Math.max(0.01, zoom);
29
39
  const viewport = React.useMemo<GridViewport>(
30
40
  () => ({
@@ -41,13 +51,50 @@ export function GridView({
41
51
  () => getVisibleGridItems(items, viewport, overscanPixels),
42
52
  [items, overscanPixels, viewport],
43
53
  );
54
+ const viewportVisibleIds = React.useMemo(
55
+ () => getVisibleGridItems(visibleItems, viewport).map((item) => item.id),
56
+ [viewport, visibleItems],
57
+ );
58
+ const previousVisibleIdsRef = React.useRef<readonly string[]>([]);
59
+
60
+ React.useEffect(() => {
61
+ if (!onVisibleItemIdsChange) return;
62
+ const previous = previousVisibleIdsRef.current;
63
+ if (
64
+ previous.length === viewportVisibleIds.length &&
65
+ previous.every((id, index) => id === viewportVisibleIds.at(index))
66
+ ) {
67
+ return;
68
+ }
69
+ previousVisibleIdsRef.current = viewportVisibleIds;
70
+ onVisibleItemIdsChange(viewportVisibleIds);
71
+ }, [onVisibleItemIdsChange, viewportVisibleIds]);
44
72
 
45
73
  React.useEffect(() => {
46
74
  onViewportChange?.(viewport);
47
75
  }, [onViewportChange, viewport]);
48
76
 
77
+ const viewportRef = React.useRef(viewport);
78
+ React.useEffect(() => {
79
+ viewportRef.current = viewport;
80
+ }, [viewport]);
81
+
82
+ React.useEffect(() => {
83
+ const currentViewport = viewportRef.current;
84
+ const focusedItem = items.find((item) => item.id === focusedItemId);
85
+ if (!focusedItem) return;
86
+ const revealed = revealWorldRect(currentViewport, focusedItem, revealPaddingPixels);
87
+ if (revealed.offsetX !== currentViewport.offsetX) {
88
+ horizontalScrollRef.current?.scrollTo({ x: revealed.offsetX * scale, animated: true });
89
+ }
90
+ if (revealed.offsetY !== currentViewport.offsetY) {
91
+ verticalScrollRef.current?.scrollTo({ y: revealed.offsetY * scale, animated: true });
92
+ }
93
+ }, [focusedItemId, height, items, revealPaddingPixels, scale, width]);
94
+
49
95
  return (
50
96
  <NativeScrollView
97
+ ref={horizontalScrollRef}
51
98
  horizontal
52
99
  scrollEnabled={interactionPolicy !== 'passive'}
53
100
  scrollEventThrottle={32}
@@ -57,6 +104,7 @@ export function GridView({
57
104
  onScroll={(event) => setScrollX(event.nativeEvent.contentOffset.x)}
58
105
  >
59
106
  <NativeScrollView
107
+ ref={verticalScrollRef}
60
108
  scrollEnabled={interactionPolicy !== 'passive'}
61
109
  scrollEventThrottle={32}
62
110
  showsVerticalScrollIndicator
@@ -14,6 +14,10 @@ export function TileGrid({
14
14
  gap = 12,
15
15
  zoom = 1,
16
16
  overscanPixels,
17
+ focusedItemId,
18
+ revealPaddingPixels,
19
+ onColumnsChange,
20
+ onVisibleItemIdsChange,
17
21
  renderItem,
18
22
  interactionPolicy,
19
23
  testID,
@@ -26,6 +30,10 @@ export function TileGrid({
26
30
  );
27
31
  const itemLookup = React.useMemo(() => new Map(items.map((item) => [item.id, item])), [items]);
28
32
 
33
+ React.useEffect(() => {
34
+ if (resolvedWidth > 0) onColumnsChange?.(layout.columns);
35
+ }, [layout.columns, onColumnsChange, resolvedWidth]);
36
+
29
37
  return (
30
38
  <NativeView
31
39
  onLayout={(event) => setMeasuredWidth(event.nativeEvent.layout.width)}
@@ -37,13 +45,16 @@ export function TileGrid({
37
45
  contentHeight={layout.height}
38
46
  contentWidth={Math.max(resolvedWidth / zoom, layout.width)}
39
47
  height={height}
48
+ focusedItemId={focusedItemId}
40
49
  interactionPolicy={interactionPolicy}
41
50
  items={layout.items}
51
+ onVisibleItemIdsChange={onVisibleItemIdsChange}
42
52
  overscanPixels={overscanPixels}
43
53
  renderItem={(item) => {
44
54
  const source = itemLookup.get(item.id);
45
55
  return source ? renderItem(source) : null;
46
56
  }}
57
+ revealPaddingPixels={revealPaddingPixels}
47
58
  width={resolvedWidth}
48
59
  zoom={zoom}
49
60
  />
@@ -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;
@@ -12,7 +12,12 @@ export interface GridViewProps extends ZoraBaseProps {
12
12
  height: number;
13
13
  zoom?: number;
14
14
  overscanPixels?: number;
15
+ /** Stable item identity that must be scrolled into view without duplicating viewport geometry. */
16
+ focusedItemId?: string;
17
+ /** Pixel inset retained around a revealed focused item. */
18
+ revealPaddingPixels?: number;
15
19
  onViewportChange?: (viewport: GridViewport) => void;
20
+ onVisibleItemIdsChange?: (ids: readonly string[]) => void;
16
21
  renderItem: (item: GridRectItem) => React.ReactNode;
17
22
  }
18
23
 
@@ -30,5 +35,10 @@ export interface TileGridProps extends ZoraBaseProps {
30
35
  gap?: number;
31
36
  zoom?: number;
32
37
  overscanPixels?: number;
38
+ focusedItemId?: string;
39
+ revealPaddingPixels?: number;
40
+ /** Reports the same measured column count used to position tiles. */
41
+ onColumnsChange?: (columns: number) => void;
42
+ onVisibleItemIdsChange?: (ids: readonly string[]) => void;
33
43
  renderItem: (item: TileGridItem) => React.ReactNode;
34
44
  }