@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
@@ -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 packageCapabilities = (
9
- JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) as {
10
- readonly ankh?: { readonly capabilities?: unknown };
11
- }
12
- ).ankh?.capabilities;
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
  });
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { Pressable as NativePressable, View as NativeView } from 'react-native';
2
+ import { Platform, Pressable as NativePressable, View as NativeView } from 'react-native';
3
3
 
4
4
  import type { ExplorerItem, ExplorerProps } from '../../../../types/explorer';
5
5
  import { TileGrid } from '../../../grid-view/public';
@@ -9,6 +9,13 @@ 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';
17
+ import { ExplorerKeyboardProxy } from './ExplorerKeyboardProxy';
18
+ import { useExplorerKeyboardFocus } from './useExplorerKeyboardFocus';
12
19
 
13
20
  /*** Single canonical Explorer implementation for media and file catalogue presentation. */
14
21
  export const Explorer = withZoraThemeScope(ExplorerInner);
@@ -26,6 +33,14 @@ function ExplorerInner({
26
33
  tileSize = 120,
27
34
  zoom = 1,
28
35
  loading = false,
36
+ hasMore = false,
37
+ loadingMore = false,
38
+ pagingCollectionId,
39
+ pagingRetryToken,
40
+ onLoadMore,
41
+ permissionStatus = 'granted',
42
+ permissionText,
43
+ onRequestPermission,
29
44
  errorText,
30
45
  emptyText = 'No items',
31
46
  disabled = false,
@@ -37,12 +52,55 @@ function ExplorerInner({
37
52
  const [internalSelectedIds, setInternalSelectedIds] =
38
53
  React.useState<readonly string[]>(defaultSelectedIds);
39
54
  const anchorId = React.useRef<string | null>(null);
55
+ const [columns, setColumns] = React.useState(1);
56
+ const [visibleIds, setVisibleIds] = React.useState<readonly string[]>([]);
57
+ const lastPageRequestRef = React.useRef<ExplorerPageRequestSignature | null>(null);
40
58
  const effectiveSelectedIds = selectedIds ?? internalSelectedIds;
41
59
  const itemLookup = React.useMemo(() => new Map(items.map((item) => [item.id, item])), [items]);
42
- const ids = items.filter((item) => !item.disabled).map((item) => item.id);
60
+ const ids = React.useMemo(
61
+ () => items.filter((item) => !item.disabled).map((item) => item.id),
62
+ [items],
63
+ );
43
64
  const selected = new Set(effectiveSelectedIds);
44
65
  const passive = interactionPolicy === 'passive' || disabled || readOnly;
45
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
+
46
104
  const select = (item: ExplorerItem, intent: 'replace' | 'toggle' | 'range') => {
47
105
  if (passive || item.disabled) return;
48
106
  const next = resolveExplorerSelection(
@@ -58,75 +116,122 @@ function ExplorerInner({
58
116
  onSelectionChange?.({ selectedIds: next });
59
117
  };
60
118
 
119
+ const keyboard = useExplorerKeyboardFocus(
120
+ items,
121
+ columns,
122
+ passive,
123
+ visibleIds,
124
+ (targetId, originId, shiftKey) => {
125
+ if (!shiftKey) return;
126
+ anchorId.current ??= originId;
127
+ const nextItem = itemLookup.get(targetId);
128
+ if (nextItem) select(nextItem, 'range');
129
+ },
130
+ );
131
+
61
132
  const renderTile = (tile: { readonly id: string }) => {
62
133
  const item = itemLookup.get(tile.id);
63
134
  if (!item) return null;
64
135
  const isSelected = selected.has(item.id);
65
136
  const canInteract = !passive && !item.disabled;
66
137
  return (
67
- <NativePressable
68
- accessibilityLabel={item.name}
69
- accessibilityRole="button"
70
- accessibilityState={{ selected: isSelected, disabled: !canInteract }}
71
- disabled={!canInteract}
72
- onLongPress={() => select(item, 'toggle')}
73
- onPress={(event) => {
74
- const intent = resolveExplorerPressIntent(event.nativeEvent);
75
- select(item, intent);
76
- if (selectionMode === 'single' && intent === 'replace') {
77
- onActivate?.({ id: item.id });
78
- }
79
- }}
80
- style={{
81
- flex: 1,
82
- overflow: 'hidden',
83
- borderRadius: 8,
84
- borderWidth: isSelected ? 2 : 1,
85
- borderColor: isSelected ? theme.colors.primary : theme.semantics.neutral.divider,
86
- backgroundColor: theme.semantics.neutral.surface,
87
- padding: 8,
88
- gap: 4,
89
- }}
90
- testID={`explorer-item-${item.id}`}
138
+ <ExplorerKeyboardProxy
139
+ onKeyDown={(key, shiftKey) => keyboard.onKeyDown(item.id, key, shiftKey)}
91
140
  >
92
- <NativeView style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
93
- {item.thumbnailUri ? (
94
- <Image
95
- accessibilityLabel={item.name}
96
- aspectRatio={1}
97
- source={item.thumbnailUri}
98
- width="100%"
99
- />
100
- ) : (
101
- <Icon name={item.kind === 'folder' ? 'folder-outline' : 'document-outline'} size={32} />
102
- )}
103
- </NativeView>
104
- <Text numberOfLines={1} variant="caption">
105
- {item.name}
106
- </Text>
107
- </NativePressable>
141
+ <NativePressable
142
+ accessibilityLabel={item.name}
143
+ accessibilityRole="button"
144
+ accessibilityState={{ selected: isSelected, disabled: !canInteract }}
145
+ disabled={!canInteract}
146
+ focusable={canInteract && (Platform.OS !== 'web' || item.id === keyboard.tabStopId)}
147
+ onFocus={() => keyboard.onFocus(item.id)}
148
+ onLongPress={() => select(item, 'toggle')}
149
+ onPress={(event) => {
150
+ const intent = resolveExplorerPressIntent(event.nativeEvent);
151
+ select(item, intent);
152
+ if (selectionMode === 'single' && intent === 'replace') {
153
+ onActivate?.({ id: item.id });
154
+ }
155
+ }}
156
+ style={{
157
+ flex: 1,
158
+ overflow: 'hidden',
159
+ borderRadius: 8,
160
+ borderWidth: isSelected ? 2 : 1,
161
+ borderColor: isSelected ? theme.colors.primary : theme.semantics.neutral.divider,
162
+ backgroundColor: theme.semantics.neutral.surface,
163
+ padding: 8,
164
+ gap: 4,
165
+ }}
166
+ ref={(node) => keyboard.registerTile(item.id, node)}
167
+ testID={`explorer-item-${item.id}`}
168
+ >
169
+ <NativeView style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
170
+ {item.thumbnailUri ? (
171
+ <Image
172
+ accessibilityLabel={item.name}
173
+ aspectRatio={1}
174
+ source={item.thumbnailUri}
175
+ width="100%"
176
+ />
177
+ ) : (
178
+ <Icon
179
+ name={item.kind === 'folder' ? 'folder-outline' : 'document-outline'}
180
+ size={32}
181
+ />
182
+ )}
183
+ </NativeView>
184
+ <Text numberOfLines={1} variant="caption">
185
+ {item.name}
186
+ </Text>
187
+ </NativePressable>
188
+ </ExplorerKeyboardProxy>
108
189
  );
109
190
  };
110
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
+ }
111
204
  if (errorText) return <Text testID={testID}>{errorText}</Text>;
112
205
  if (loading) return <Text testID={testID}>Loading…</Text>;
113
206
  if (items.length === 0) return <Text testID={testID}>{emptyText}</Text>;
114
207
 
115
208
  return (
116
- <TileGrid
117
- height={height}
118
- interactionPolicy={interactionPolicy}
119
- items={items}
120
- renderItem={renderTile}
121
- testID={testID}
122
- tileSize={tileSize}
123
- width={width}
124
- zoom={zoom}
125
- />
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>
126
228
  );
127
229
  }
128
230
 
129
- /*** Normalize platform press modifiers for multi-selection without passing event objects into contracts. */
231
+ /***
232
+ * Normalize platform press modifiers into selection intents
233
+ * without passing event objects into the public contracts.
234
+ */
130
235
  function resolveExplorerPressIntent(event: unknown): 'replace' | 'toggle' | 'range' {
131
236
  if (typeof event !== 'object' || event === null) return 'replace';
132
237
  if ('shiftKey' in event && event.shiftKey === true) return 'range';
@@ -0,0 +1,11 @@
1
+ import type React from 'react';
2
+
3
+ /*** Keeps Explorer navigation portable when a native host has no DOM keyboard event surface. */
4
+ export function ExplorerKeyboardProxy({ children }: ExplorerKeyboardProxyProps) {
5
+ return <>{children}</>;
6
+ }
7
+
8
+ interface ExplorerKeyboardProxyProps {
9
+ readonly children: React.ReactNode;
10
+ readonly onKeyDown: (key: string, shiftKey: boolean) => boolean;
11
+ }
@@ -0,0 +1,11 @@
1
+ import type React from 'react';
2
+
3
+ /*** Provides the native fallback type surface; RNW resolves the web adapter at runtime. */
4
+ export function ExplorerKeyboardProxy({ children }: ExplorerKeyboardProxyProps) {
5
+ return <>{children}</>;
6
+ }
7
+
8
+ interface ExplorerKeyboardProxyProps {
9
+ readonly children: React.ReactNode;
10
+ readonly onKeyDown: (key: string, shiftKey: boolean) => boolean;
11
+ }
@@ -0,0 +1,20 @@
1
+ import type React from 'react';
2
+
3
+ /*** Bridges RNW's rendered DOM focus events into Explorer's platform-neutral navigation policy. */
4
+ export function ExplorerKeyboardProxy({ children, onKeyDown }: ExplorerKeyboardProxyProps) {
5
+ return (
6
+ <div
7
+ onKeyDown={(event) => {
8
+ if (onKeyDown(event.key, event.shiftKey)) event.preventDefault();
9
+ }}
10
+ style={{ display: 'flex', flex: 1 }}
11
+ >
12
+ {children}
13
+ </div>
14
+ );
15
+ }
16
+
17
+ interface ExplorerKeyboardProxyProps {
18
+ readonly children: React.ReactNode;
19
+ readonly onKeyDown: (key: string, shiftKey: boolean) => boolean;
20
+ }
@@ -0,0 +1,232 @@
1
+ import { expect, test } from 'bun:test';
2
+ import { Window } from 'happy-dom';
3
+ import React, { act } from 'react';
4
+ import { createRoot } from 'react-dom/client';
5
+
6
+ import type { ExplorerItem } from '../../../../types/explorer';
7
+ import { layoutTileGrid } from '../../../grid-view/application/layoutTileGrid';
8
+ import { ExplorerKeyboardProxy } from './ExplorerKeyboardProxy.web';
9
+ import { useExplorerKeyboardFocus } from './useExplorerKeyboardFocus';
10
+
11
+ interface HarnessProps {
12
+ readonly items: readonly ExplorerItem[];
13
+ readonly mountedIds: readonly string[];
14
+ readonly columns: number;
15
+ readonly navigation: { origin: string; target: string; extend: boolean }[];
16
+ }
17
+
18
+ function FocusHarness({ items, mountedIds, columns, navigation }: HarnessProps) {
19
+ const keyboard = useExplorerKeyboardFocus(
20
+ items,
21
+ columns,
22
+ false,
23
+ mountedIds,
24
+ (target, origin, extend) => {
25
+ navigation.push({ origin, target, extend });
26
+ },
27
+ );
28
+ return (
29
+ <div>
30
+ {mountedIds.map((id) => (
31
+ <ExplorerKeyboardProxy
32
+ key={id}
33
+ onKeyDown={(key, shiftKey) => keyboard.onKeyDown(id, key, shiftKey)}
34
+ >
35
+ <button
36
+ data-tile={id}
37
+ onFocus={() => keyboard.onFocus(id)}
38
+ ref={(node) => keyboard.registerTile(id, node)}
39
+ tabIndex={id === keyboard.tabStopId ? 0 : -1}
40
+ type="button"
41
+ >
42
+ {id}
43
+ </button>
44
+ </ExplorerKeyboardProxy>
45
+ ))}
46
+ </div>
47
+ );
48
+ }
49
+
50
+ test('restores native DOM focus only after a far virtual tile mounts', () => {
51
+ const browser = new Window();
52
+ const restore = installBrowserGlobals(browser);
53
+ const host = document.createElement('div');
54
+ document.body.append(host);
55
+ const root = createRoot(host);
56
+ const navigation: { origin: string; target: string; extend: boolean }[] = [];
57
+ const items = Array.from({ length: 10000 }, (_, i): ExplorerItem => ({
58
+ id: `tile-${i}`,
59
+ name: `Tile ${i}`,
60
+ kind: 'image',
61
+ }));
62
+ const render = (mountedIds: string[]) => (
63
+ <FocusHarness columns={4} items={items} mountedIds={mountedIds} navigation={navigation} />
64
+ );
65
+
66
+ try {
67
+ act(() => root.render(render(['tile-0', 'tile-1', 'tile-2', 'tile-3'])));
68
+ const first = host.querySelector<HTMLButtonElement>('[data-tile="tile-0"]');
69
+ if (!first) throw new Error('Missing first tile');
70
+ act(() => first.focus());
71
+
72
+ const event = new browser.KeyboardEvent('keydown', {
73
+ key: 'End',
74
+ bubbles: true,
75
+ cancelable: true,
76
+ });
77
+ act(() => {
78
+ first.dispatchEvent(event);
79
+ });
80
+
81
+ expect(event.defaultPrevented).toBe(true);
82
+ expect(navigation).toEqual([{ origin: 'tile-0', target: 'tile-9999', extend: false }]);
83
+ expect(host.querySelector('[data-tile="tile-9999"]')).toBeNull();
84
+
85
+ act(() => root.render(render(['tile-9996', 'tile-9997', 'tile-9998', 'tile-9999'])));
86
+ expect(document.activeElement?.getAttribute('data-tile')).toBe('tile-9999');
87
+ expect(host.querySelectorAll('[tabindex="0"]')).toHaveLength(1);
88
+ } finally {
89
+ act(() => root.unmount());
90
+ host.remove();
91
+ browser.close();
92
+ restore();
93
+ }
94
+ });
95
+
96
+ test('restores focus after rapid navigation, resize and unavailable tiles', () => {
97
+ const browser = new Window();
98
+ const restore = installBrowserGlobals(browser);
99
+ const host = document.createElement('div');
100
+ document.body.append(host);
101
+ const root = createRoot(host);
102
+ const navigation: { origin: string; target: string; extend: boolean }[] = [];
103
+ const items: readonly ExplorerItem[] = [
104
+ { id: 'a', name: 'A', kind: 'file' },
105
+ { id: 'b', name: 'B', kind: 'file' },
106
+ { id: 'c', name: 'C', kind: 'file', disabled: true },
107
+ { id: 'd', name: 'D', kind: 'file' },
108
+ { id: 'e', name: 'E', kind: 'file' },
109
+ { id: 'f', name: 'F', kind: 'file' },
110
+ ];
111
+ const render = (width: number, mountedIds: string[]) => (
112
+ <FocusHarness
113
+ columns={layoutTileGrid(items, width, 100, 10).columns}
114
+ items={items}
115
+ mountedIds={mountedIds}
116
+ navigation={navigation}
117
+ />
118
+ );
119
+ try {
120
+ act(() => root.render(render(450, ['a', 'b', 'c', 'd'])));
121
+ const first = host.querySelector<HTMLButtonElement>('[data-tile="a"]');
122
+ if (!first) throw new Error('Missing first tile');
123
+ act(() => first.focus());
124
+
125
+ act(() => {
126
+ first.dispatchEvent(
127
+ new browser.KeyboardEvent('keydown', {
128
+ key: 'ArrowDown',
129
+ shiftKey: true,
130
+ bubbles: true,
131
+ cancelable: true,
132
+ }),
133
+ );
134
+ });
135
+ expect(navigation.at(-1)).toEqual({ origin: 'a', target: 'e', extend: true });
136
+
137
+ act(() => root.render(render(230, ['a', 'b', 'c', 'd'])));
138
+ act(() => {
139
+ first.dispatchEvent(new browser.KeyboardEvent('keydown', { key: 'Home', bubbles: true }));
140
+ });
141
+ act(() => {
142
+ first.dispatchEvent(
143
+ new browser.KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
144
+ );
145
+ });
146
+ expect(navigation.at(-1)).toEqual({ origin: 'a', target: 'd', extend: false });
147
+
148
+ act(() => {
149
+ first.dispatchEvent(new browser.KeyboardEvent('keydown', { key: 'End', bubbles: true }));
150
+ });
151
+ act(() => {
152
+ first.dispatchEvent(new browser.KeyboardEvent('keydown', { key: 'Home', bubbles: true }));
153
+ });
154
+ act(() => root.render(render(230, ['a', 'b', 'c', 'd', 'e', 'f'])));
155
+ expect(document.activeElement?.getAttribute('data-tile')).toBe('a');
156
+ expect(host.querySelectorAll('[tabindex="0"]')).toHaveLength(1);
157
+ expect(host.querySelector<HTMLButtonElement>('[data-tile="c"]')?.tabIndex).toBe(-1);
158
+
159
+ act(() => root.render(render(230, ['d', 'e', 'f'])));
160
+ expect(host.querySelector<HTMLButtonElement>('[data-tile="d"]')?.tabIndex).toBe(0);
161
+ expect(host.querySelectorAll('[tabindex="0"]')).toHaveLength(1);
162
+ } finally {
163
+ act(() => root.unmount());
164
+ host.remove();
165
+ browser.close();
166
+ restore();
167
+ }
168
+ });
169
+
170
+ test('web proxy leaves non-navigation keys to ordinary activation', () => {
171
+ const browser = new Window();
172
+ const restore = installBrowserGlobals(browser);
173
+ const host = document.createElement('div');
174
+ document.body.append(host);
175
+ const root = createRoot(host);
176
+ try {
177
+ act(() =>
178
+ root.render(
179
+ <ExplorerKeyboardProxy onKeyDown={(key) => key === 'ArrowRight'}>
180
+ <button type="button">Action</button>
181
+ </ExplorerKeyboardProxy>,
182
+ ),
183
+ );
184
+ const button = host.querySelector('button');
185
+ if (!button) throw new Error('Missing action');
186
+ const arrow = new browser.KeyboardEvent('keydown', {
187
+ key: 'ArrowRight',
188
+ bubbles: true,
189
+ cancelable: true,
190
+ });
191
+ act(() => {
192
+ button.dispatchEvent(arrow);
193
+ });
194
+ expect(arrow.defaultPrevented).toBe(true);
195
+
196
+ const enter = new browser.KeyboardEvent('keydown', {
197
+ key: 'Enter',
198
+ bubbles: true,
199
+ cancelable: true,
200
+ });
201
+ act(() => {
202
+ button.dispatchEvent(enter);
203
+ });
204
+ expect(enter.defaultPrevented).toBe(false);
205
+ } finally {
206
+ act(() => root.unmount());
207
+ host.remove();
208
+ browser.close();
209
+ restore();
210
+ }
211
+ });
212
+
213
+ /*** Install a deterministic RNW-like browser environment for real focus and event propagation. */
214
+ function installBrowserGlobals(browser: Window): () => void {
215
+ const keys = ['window', 'document', 'Node', 'navigator', 'IS_REACT_ACT_ENVIRONMENT'] as const;
216
+ const previous = keys.map(
217
+ (key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const,
218
+ );
219
+ Object.assign(globalThis, {
220
+ window: browser,
221
+ document: browser.document,
222
+ Node: browser.Node,
223
+ navigator: browser.navigator,
224
+ IS_REACT_ACT_ENVIRONMENT: true,
225
+ });
226
+ return () => {
227
+ for (const [key, descriptor] of previous) {
228
+ if (descriptor) Object.defineProperty(globalThis, key, descriptor);
229
+ else Reflect.deleteProperty(globalThis, key);
230
+ }
231
+ };
232
+ }
@@ -0,0 +1,77 @@
1
+ import React from 'react';
2
+
3
+ import type { ExplorerItem } from '../../../../types/explorer';
4
+ import { getExplorerNextFocusId } from '../../application/getExplorerNextFocusId';
5
+
6
+ /*** Tracks the one keyboard tab stop and restores focus only after a virtualized tile mounts. */
7
+ export function useExplorerKeyboardFocus(
8
+ items: readonly ExplorerItem[],
9
+ columns: number,
10
+ disabled: boolean,
11
+ visibleIds: readonly string[],
12
+ onNavigate: (targetId: string, originId: string, shiftKey: boolean) => void,
13
+ ) {
14
+ const [focusedId, setFocusedId] = React.useState<string | null>(null);
15
+ const focusedRef = React.useRef<string | null>(null);
16
+ const pendingFocusRef = React.useRef<string | null>(null);
17
+ const tileRefs = React.useRef(new Map<string, FocusableTile>());
18
+ const ids = React.useMemo(() => items.map(({ id }) => id), [items]);
19
+ const idSet = React.useMemo(() => new Set(ids), [ids]);
20
+ const disabledIds = React.useMemo(
21
+ () => new Set(items.filter((item) => item.disabled).map(({ id }) => id)),
22
+ [items],
23
+ );
24
+ const visibleEligibleIds = visibleIds.filter((id) => idSet.has(id) && !disabledIds.has(id));
25
+ const tabStopId =
26
+ focusedId !== null && visibleEligibleIds.includes(focusedId)
27
+ ? focusedId
28
+ : (visibleEligibleIds[0] ?? ids.find((id) => !disabledIds.has(id)));
29
+
30
+ const onFocus = React.useCallback((id: string) => {
31
+ pendingFocusRef.current = null;
32
+ focusedRef.current = id;
33
+ setFocusedId(id);
34
+ }, []);
35
+
36
+ const registerTile = React.useCallback((id: string, node: FocusableTile | null) => {
37
+ if (node === null) {
38
+ tileRefs.current.delete(id);
39
+ return;
40
+ }
41
+ tileRefs.current.set(id, node);
42
+ if (pendingFocusRef.current === id) {
43
+ pendingFocusRef.current = null;
44
+ node.focus();
45
+ }
46
+ }, []);
47
+
48
+ const onKeyDown = React.useCallback(
49
+ (id: string, key: string, shiftKey: boolean): boolean => {
50
+ if (disabled) return false;
51
+ if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(key))
52
+ return false;
53
+ const originId = focusedRef.current ?? id;
54
+ const nextId = getExplorerNextFocusId(ids, originId, key, columns, disabledIds);
55
+ if (nextId === null || nextId === originId) return true;
56
+
57
+ focusedRef.current = nextId;
58
+ pendingFocusRef.current = nextId;
59
+ setFocusedId(nextId);
60
+ onNavigate(nextId, originId, shiftKey);
61
+
62
+ const mounted = tileRefs.current.get(nextId);
63
+ if (mounted) {
64
+ pendingFocusRef.current = null;
65
+ mounted.focus();
66
+ }
67
+ return true;
68
+ },
69
+ [columns, disabled, disabledIds, ids, onNavigate],
70
+ );
71
+
72
+ return { focusedId, tabStopId, onFocus, onKeyDown, registerTile };
73
+ }
74
+
75
+ interface FocusableTile {
76
+ focus: () => void;
77
+ }