@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.
- package/CHANGELOG.md +13 -0
- package/README.md +2 -2
- package/dist/features/explorer/adapters/inbound/Explorer.d.ts.map +1 -1
- package/dist/features/explorer/adapters/inbound/Explorer.js +72 -14
- package/dist/features/explorer/adapters/inbound/Explorer.js.map +1 -1
- package/dist/features/explorer/adapters/inbound/ExplorerKeyboardProxy.d.ts +9 -0
- package/dist/features/explorer/adapters/inbound/ExplorerKeyboardProxy.d.ts.map +1 -0
- package/dist/features/explorer/adapters/inbound/ExplorerKeyboardProxy.js +5 -0
- package/dist/features/explorer/adapters/inbound/ExplorerKeyboardProxy.js.map +1 -0
- package/dist/features/explorer/adapters/inbound/ExplorerKeyboardProxy.native.d.ts +9 -0
- package/dist/features/explorer/adapters/inbound/ExplorerKeyboardProxy.native.d.ts.map +1 -0
- package/dist/features/explorer/adapters/inbound/ExplorerKeyboardProxy.native.js +5 -0
- package/dist/features/explorer/adapters/inbound/ExplorerKeyboardProxy.native.js.map +1 -0
- package/dist/features/explorer/adapters/inbound/ExplorerKeyboardProxy.web.d.ts +9 -0
- package/dist/features/explorer/adapters/inbound/ExplorerKeyboardProxy.web.d.ts.map +1 -0
- package/dist/features/explorer/adapters/inbound/ExplorerKeyboardProxy.web.js +10 -0
- package/dist/features/explorer/adapters/inbound/ExplorerKeyboardProxy.web.js.map +1 -0
- package/dist/features/explorer/adapters/inbound/useExplorerKeyboardFocus.d.ts +14 -0
- package/dist/features/explorer/adapters/inbound/useExplorerKeyboardFocus.d.ts.map +1 -0
- package/dist/features/explorer/adapters/inbound/useExplorerKeyboardFocus.js +54 -0
- package/dist/features/explorer/adapters/inbound/useExplorerKeyboardFocus.js.map +1 -0
- package/dist/features/explorer/application/getExplorerNextFocusId.d.ts +6 -0
- package/dist/features/explorer/application/getExplorerNextFocusId.d.ts.map +1 -0
- package/dist/features/explorer/application/getExplorerNextFocusId.js +53 -0
- package/dist/features/explorer/application/getExplorerNextFocusId.js.map +1 -0
- 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 +2 -1
- package/dist/features/explorer/public.d.ts.map +1 -1
- package/dist/features/explorer/public.js +1 -0
- package/dist/features/explorer/public.js.map +1 -1
- package/dist/features/grid-view/adapters/inbound/GridView.d.ts +1 -1
- package/dist/features/grid-view/adapters/inbound/GridView.d.ts.map +1 -1
- package/dist/features/grid-view/adapters/inbound/GridView.js +36 -4
- package/dist/features/grid-view/adapters/inbound/GridView.js.map +1 -1
- package/dist/features/grid-view/adapters/inbound/TileGrid.d.ts +1 -1
- package/dist/features/grid-view/adapters/inbound/TileGrid.d.ts.map +1 -1
- package/dist/features/grid-view/adapters/inbound/TileGrid.js +7 -3
- package/dist/features/grid-view/adapters/inbound/TileGrid.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/dist/types/grid-view.d.ts +10 -0
- package/dist/types/grid-view.d.ts.map +1 -1
- package/dist/types/grid-view.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 +158 -53
- package/src/features/explorer/adapters/inbound/ExplorerKeyboardProxy.native.tsx +11 -0
- package/src/features/explorer/adapters/inbound/ExplorerKeyboardProxy.tsx +11 -0
- package/src/features/explorer/adapters/inbound/ExplorerKeyboardProxy.web.tsx +20 -0
- package/src/features/explorer/adapters/inbound/useExplorerKeyboardFocus.test.tsx +232 -0
- package/src/features/explorer/adapters/inbound/useExplorerKeyboardFocus.ts +77 -0
- package/src/features/explorer/application/getExplorerNextFocusId.test.ts +37 -0
- package/src/features/explorer/application/getExplorerNextFocusId.ts +61 -0
- 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 +3 -0
- package/src/features/grid-view/adapters/inbound/GridView.tsx +49 -1
- package/src/features/grid-view/adapters/inbound/TileGrid.tsx +11 -0
- package/src/types/explorer.ts +30 -0
- package/src/types/grid-view.ts +10 -0
- package/web-dist/chunks/{Accordion-b20zfsfz.js → Accordion-54k0ncs3.js} +83 -0
- package/web-dist/chunks/Accordion-kkr7xfh2.js +397 -0
- package/web-dist/components/explorer/Explorer.js +2 -2
- package/web-dist/components/file-explorer/FileExplorer.js +2 -2
- package/web-dist/components/grid-view/GridView.js +1 -1
- package/web-dist/components/media-explorer/MediaExplorer.js +2 -2
- package/web-dist/components/tile-grid/TileGrid.js +1 -1
- package/web-dist/components/uploader/Uploader.js +2 -2
- package/web-dist/manifest.json +10 -10
- 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
|
|
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
|
});
|
|
@@ -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 =
|
|
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
|
-
<
|
|
68
|
-
|
|
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
|
-
<
|
|
93
|
-
{item.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
<
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
/***
|
|
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
|
+
}
|