@ankhorage/zora 25.0.0 → 25.1.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 +12 -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 +30 -13
- 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/public.d.ts +1 -0
- 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/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 +3 -3
- package/src/features/explorer/adapters/inbound/Explorer.tsx +79 -43
- 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/public.ts +1 -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/grid-view.ts +10 -0
- package/web-dist/chunks/{Accordion-8m8p9wpw.js → Accordion-54k0ncs3.js} +83 -0
- package/web-dist/chunks/Accordion-fmn31fgf.js +321 -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-2gvs3frs.js +0 -197
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import type { TileGridProps } from '../../../../types/grid-view';
|
|
3
3
|
/*** Composes a responsive, virtualized tile presentation over the generic GridView renderer. */
|
|
4
|
-
export declare function TileGrid({ items, width, height, tileSize, gap, zoom, overscanPixels, renderItem, interactionPolicy, testID, }: TileGridProps): React.JSX.Element;
|
|
4
|
+
export declare function TileGrid({ items, width, height, tileSize, gap, zoom, overscanPixels, focusedItemId, revealPaddingPixels, onColumnsChange, onVisibleItemIdsChange, renderItem, interactionPolicy, testID, }: TileGridProps): React.JSX.Element;
|
|
5
5
|
//# sourceMappingURL=TileGrid.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TileGrid.d.ts","sourceRoot":"","sources":["../../../../../src/features/grid-view/adapters/inbound/TileGrid.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAG1B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAC;AAIjE,gGAAgG;AAChG,wBAAgB,QAAQ,CAAC,EACvB,KAAK,EACL,KAAK,EACL,MAAY,EACZ,QAAc,EACd,GAAQ,EACR,IAAQ,EACR,cAAc,EACd,UAAU,EACV,iBAAiB,EACjB,MAAM,GACP,EAAE,aAAa,
|
|
1
|
+
{"version":3,"file":"TileGrid.d.ts","sourceRoot":"","sources":["../../../../../src/features/grid-view/adapters/inbound/TileGrid.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAG1B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAC;AAIjE,gGAAgG;AAChG,wBAAgB,QAAQ,CAAC,EACvB,KAAK,EACL,KAAK,EACL,MAAY,EACZ,QAAc,EACd,GAAQ,EACR,IAAQ,EACR,cAAc,EACd,aAAa,EACb,mBAAmB,EACnB,eAAe,EACf,sBAAsB,EACtB,UAAU,EACV,iBAAiB,EACjB,MAAM,GACP,EAAE,aAAa,qBAwCf"}
|
|
@@ -3,16 +3,20 @@ import { View as NativeView } from 'react-native';
|
|
|
3
3
|
import { layoutTileGrid } from '../../application/layoutTileGrid';
|
|
4
4
|
import { GridView } from './GridView';
|
|
5
5
|
/*** Composes a responsive, virtualized tile presentation over the generic GridView renderer. */
|
|
6
|
-
export function TileGrid({ items, width, height = 440, tileSize = 120, gap = 12, zoom = 1, overscanPixels, renderItem, interactionPolicy, testID, }) {
|
|
6
|
+
export function TileGrid({ items, width, height = 440, tileSize = 120, gap = 12, zoom = 1, overscanPixels, focusedItemId, revealPaddingPixels, onColumnsChange, onVisibleItemIdsChange, renderItem, interactionPolicy, testID, }) {
|
|
7
7
|
const [measuredWidth, setMeasuredWidth] = React.useState(0);
|
|
8
8
|
const resolvedWidth = width ?? measuredWidth;
|
|
9
9
|
const layout = React.useMemo(() => layoutTileGrid(items, resolvedWidth, tileSize, gap, zoom), [items, resolvedWidth, tileSize, gap, zoom]);
|
|
10
10
|
const itemLookup = React.useMemo(() => new Map(items.map((item) => [item.id, item])), [items]);
|
|
11
|
+
React.useEffect(() => {
|
|
12
|
+
if (resolvedWidth > 0)
|
|
13
|
+
onColumnsChange?.(layout.columns);
|
|
14
|
+
}, [layout.columns, onColumnsChange, resolvedWidth]);
|
|
11
15
|
return (<NativeView onLayout={(event) => setMeasuredWidth(event.nativeEvent.layout.width)} style={{ width, height, overflow: 'hidden' }} testID={testID}>
|
|
12
|
-
{resolvedWidth > 0 ? (<GridView contentHeight={layout.height} contentWidth={Math.max(resolvedWidth / zoom, layout.width)} height={height} interactionPolicy={interactionPolicy} items={layout.items} overscanPixels={overscanPixels} renderItem={(item) => {
|
|
16
|
+
{resolvedWidth > 0 ? (<GridView contentHeight={layout.height} contentWidth={Math.max(resolvedWidth / zoom, layout.width)} height={height} focusedItemId={focusedItemId} interactionPolicy={interactionPolicy} items={layout.items} onVisibleItemIdsChange={onVisibleItemIdsChange} overscanPixels={overscanPixels} renderItem={(item) => {
|
|
13
17
|
const source = itemLookup.get(item.id);
|
|
14
18
|
return source ? renderItem(source) : null;
|
|
15
|
-
}} width={resolvedWidth} zoom={zoom}/>) : null}
|
|
19
|
+
}} revealPaddingPixels={revealPaddingPixels} width={resolvedWidth} zoom={zoom}/>) : null}
|
|
16
20
|
</NativeView>);
|
|
17
21
|
}
|
|
18
22
|
//# sourceMappingURL=TileGrid.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TileGrid.js","sourceRoot":"","sources":["../../../../../src/features/grid-view/adapters/inbound/TileGrid.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAC1B,OAAO,EAAE,IAAI,IAAI,UAAU,EAAE,MAAM,cAAc,CAAC;AAGlD,OAAO,EAAE,cAAc,EAAE,MAAM,kCAAkC,CAAC;AAClE,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAEtC,gGAAgG;AAChG,MAAM,UAAU,QAAQ,CAAC,EACvB,KAAK,EACL,KAAK,EACL,MAAM,GAAG,GAAG,EACZ,QAAQ,GAAG,GAAG,EACd,GAAG,GAAG,EAAE,EACR,IAAI,GAAG,CAAC,EACR,cAAc,EACd,UAAU,EACV,iBAAiB,EACjB,MAAM,GACQ;IACd,MAAM,CAAC,aAAa,EAAE,gBAAgB,CAAC,GAAG,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC;IAC5D,MAAM,aAAa,GAAG,KAAK,IAAI,aAAa,CAAC;IAC7C,MAAM,MAAM,GAAG,KAAK,CAAC,OAAO,CAC1B,GAAG,EAAE,CAAC,cAAc,CAAC,KAAK,EAAE,aAAa,EAAE,QAAQ,EAAE,GAAG,EAAE,IAAI,CAAC,EAC/D,CAAC,KAAK,EAAE,aAAa,EAAE,QAAQ,EAAE,GAAG,EAAE,IAAI,CAAC,CAC5C,CAAC;IACF,MAAM,UAAU,GAAG,KAAK,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,IAAI,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,EAAE,EAAE,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC;IAE/F,OAAO,CACL,CAAC,UAAU,CACT,QAAQ,CAAC,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,gBAAgB,CAAC,KAAK,CAAC,WAAW,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CACtE,KAAK,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,CAAC,CAC7C,MAAM,CAAC,CAAC,MAAM,CAAC,CAEf;MAAA,CAAC,aAAa,GAAG,CAAC,CAAC,CAAC,CAAC,CACnB,CAAC,QAAQ,CACP,aAAa,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAC7B,YAAY,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,aAAa,GAAG,IAAI,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAC3D,MAAM,CAAC,CAAC,MAAM,CAAC,CACf,iBAAiB,CAAC,CAAC,iBAAiB,CAAC,CACrC,KAAK,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CACpB,cAAc,CAAC,CAAC,cAAc,CAAC,CAC/B,UAAU,CAAC,CAAC,CAAC,IAAI,EAAE,EAAE;gBACnB,MAAM,MAAM,GAAG,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;gBACvC,OAAO,MAAM,CAAC,CAAC,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;YAC5C,CAAC,CAAC,CACF,KAAK,CAAC,CAAC,aAAa,CAAC,CACrB,IAAI,CAAC,CAAC,IAAI,CAAC,EACX,CACH,CAAC,CAAC,CAAC,IAAI,CACV;IAAA,EAAE,UAAU,CAAC,CACd,CAAC;AACJ,CAAC","sourcesContent":["import React from 'react';\nimport { View as NativeView } from 'react-native';\n\nimport type { TileGridProps } from '../../../../types/grid-view';\nimport { layoutTileGrid } from '../../application/layoutTileGrid';\nimport { GridView } from './GridView';\n\n/*** Composes a responsive, virtualized tile presentation over the generic GridView renderer. */\nexport function TileGrid({\n items,\n width,\n height = 440,\n tileSize = 120,\n gap = 12,\n zoom = 1,\n overscanPixels,\n renderItem,\n interactionPolicy,\n testID,\n}: TileGridProps) {\n const [measuredWidth, setMeasuredWidth] = React.useState(0);\n const resolvedWidth = width ?? measuredWidth;\n const layout = React.useMemo(\n () => layoutTileGrid(items, resolvedWidth, tileSize, gap, zoom),\n [items, resolvedWidth, tileSize, gap, zoom],\n );\n const itemLookup = React.useMemo(() => new Map(items.map((item) => [item.id, item])), [items]);\n\n return (\n <NativeView\n onLayout={(event) => setMeasuredWidth(event.nativeEvent.layout.width)}\n style={{ width, height, overflow: 'hidden' }}\n testID={testID}\n >\n {resolvedWidth > 0 ? (\n <GridView\n contentHeight={layout.height}\n contentWidth={Math.max(resolvedWidth / zoom, layout.width)}\n height={height}\n interactionPolicy={interactionPolicy}\n items={layout.items}\n overscanPixels={overscanPixels}\n renderItem={(item) => {\n const source = itemLookup.get(item.id);\n return source ? renderItem(source) : null;\n }}\n width={resolvedWidth}\n zoom={zoom}\n />\n ) : null}\n </NativeView>\n );\n}\n"]}
|
|
1
|
+
{"version":3,"file":"TileGrid.js","sourceRoot":"","sources":["../../../../../src/features/grid-view/adapters/inbound/TileGrid.tsx"],"names":[],"mappings":"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","sourcesContent":["import React from 'react';\nimport { View as NativeView } from 'react-native';\n\nimport type { TileGridProps } from '../../../../types/grid-view';\nimport { layoutTileGrid } from '../../application/layoutTileGrid';\nimport { GridView } from './GridView';\n\n/*** Composes a responsive, virtualized tile presentation over the generic GridView renderer. */\nexport function TileGrid({\n items,\n width,\n height = 440,\n tileSize = 120,\n gap = 12,\n zoom = 1,\n overscanPixels,\n focusedItemId,\n revealPaddingPixels,\n onColumnsChange,\n onVisibleItemIdsChange,\n renderItem,\n interactionPolicy,\n testID,\n}: TileGridProps) {\n const [measuredWidth, setMeasuredWidth] = React.useState(0);\n const resolvedWidth = width ?? measuredWidth;\n const layout = React.useMemo(\n () => layoutTileGrid(items, resolvedWidth, tileSize, gap, zoom),\n [items, resolvedWidth, tileSize, gap, zoom],\n );\n const itemLookup = React.useMemo(() => new Map(items.map((item) => [item.id, item])), [items]);\n\n React.useEffect(() => {\n if (resolvedWidth > 0) onColumnsChange?.(layout.columns);\n }, [layout.columns, onColumnsChange, resolvedWidth]);\n\n return (\n <NativeView\n onLayout={(event) => setMeasuredWidth(event.nativeEvent.layout.width)}\n style={{ width, height, overflow: 'hidden' }}\n testID={testID}\n >\n {resolvedWidth > 0 ? (\n <GridView\n contentHeight={layout.height}\n contentWidth={Math.max(resolvedWidth / zoom, layout.width)}\n height={height}\n focusedItemId={focusedItemId}\n interactionPolicy={interactionPolicy}\n items={layout.items}\n onVisibleItemIdsChange={onVisibleItemIdsChange}\n overscanPixels={overscanPixels}\n renderItem={(item) => {\n const source = itemLookup.get(item.id);\n return source ? renderItem(source) : null;\n }}\n revealPaddingPixels={revealPaddingPixels}\n width={resolvedWidth}\n zoom={zoom}\n />\n ) : null}\n </NativeView>\n );\n}\n"]}
|
|
@@ -10,7 +10,12 @@ export interface GridViewProps extends ZoraBaseProps {
|
|
|
10
10
|
height: number;
|
|
11
11
|
zoom?: number;
|
|
12
12
|
overscanPixels?: number;
|
|
13
|
+
/** Stable item identity that must be scrolled into view without duplicating viewport geometry. */
|
|
14
|
+
focusedItemId?: string;
|
|
15
|
+
/** Pixel inset retained around a revealed focused item. */
|
|
16
|
+
revealPaddingPixels?: number;
|
|
13
17
|
onViewportChange?: (viewport: GridViewport) => void;
|
|
18
|
+
onVisibleItemIdsChange?: (ids: readonly string[]) => void;
|
|
14
19
|
renderItem: (item: GridRectItem) => React.ReactNode;
|
|
15
20
|
}
|
|
16
21
|
/** An item needs a stable ID; visual positioning belongs to the TileGrid composition. */
|
|
@@ -26,6 +31,11 @@ export interface TileGridProps extends ZoraBaseProps {
|
|
|
26
31
|
gap?: number;
|
|
27
32
|
zoom?: number;
|
|
28
33
|
overscanPixels?: number;
|
|
34
|
+
focusedItemId?: string;
|
|
35
|
+
revealPaddingPixels?: number;
|
|
36
|
+
/** Reports the same measured column count used to position tiles. */
|
|
37
|
+
onColumnsChange?: (columns: number) => void;
|
|
38
|
+
onVisibleItemIdsChange?: (ids: readonly string[]) => void;
|
|
29
39
|
renderItem: (item: TileGridItem) => React.ReactNode;
|
|
30
40
|
}
|
|
31
41
|
//# sourceMappingURL=grid-view.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"grid-view.d.ts","sourceRoot":"","sources":["../../src/types/grid-view.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACvE,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAE/B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,QAAQ,CAAC;AAE5C,gFAAgF;AAChF,MAAM,WAAW,aAAc,SAAQ,aAAa;IAClD,KAAK,EAAE,SAAS,YAAY,EAAE,CAAC;IAC/B,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,gBAAgB,CAAC,EAAE,CAAC,QAAQ,EAAE,YAAY,KAAK,IAAI,CAAC;IACpD,UAAU,EAAE,CAAC,IAAI,EAAE,YAAY,KAAK,KAAK,CAAC,SAAS,CAAC;CACrD;AAED,yFAAyF;AACzF,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;CACrB;AAED,6EAA6E;AAC7E,MAAM,WAAW,aAAc,SAAQ,aAAa;IAClD,KAAK,EAAE,SAAS,YAAY,EAAE,CAAC;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,CAAC,IAAI,EAAE,YAAY,KAAK,KAAK,CAAC,SAAS,CAAC;CACrD"}
|
|
1
|
+
{"version":3,"file":"grid-view.d.ts","sourceRoot":"","sources":["../../src/types/grid-view.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACvE,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAE/B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,QAAQ,CAAC;AAE5C,gFAAgF;AAChF,MAAM,WAAW,aAAc,SAAQ,aAAa;IAClD,KAAK,EAAE,SAAS,YAAY,EAAE,CAAC;IAC/B,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,kGAAkG;IAClG,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,2DAA2D;IAC3D,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,gBAAgB,CAAC,EAAE,CAAC,QAAQ,EAAE,YAAY,KAAK,IAAI,CAAC;IACpD,sBAAsB,CAAC,EAAE,CAAC,GAAG,EAAE,SAAS,MAAM,EAAE,KAAK,IAAI,CAAC;IAC1D,UAAU,EAAE,CAAC,IAAI,EAAE,YAAY,KAAK,KAAK,CAAC,SAAS,CAAC;CACrD;AAED,yFAAyF;AACzF,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;CACrB;AAED,6EAA6E;AAC7E,MAAM,WAAW,aAAc,SAAQ,aAAa;IAClD,KAAK,EAAE,SAAS,YAAY,EAAE,CAAC;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,qEAAqE;IACrE,eAAe,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5C,sBAAsB,CAAC,EAAE,CAAC,GAAG,EAAE,SAAS,MAAM,EAAE,KAAK,IAAI,CAAC;IAC1D,UAAU,EAAE,CAAC,IAAI,EAAE,YAAY,KAAK,KAAK,CAAC,SAAS,CAAC;CACrD"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"grid-view.js","sourceRoot":"","sources":["../../src/types/grid-view.ts"],"names":[],"mappings":"","sourcesContent":["import type { GridRectItem, GridViewport } from '@ankhorage/grid-view';\nimport type React from 'react';\n\nimport type { ZoraBaseProps } from './base';\n\n/** The renderer owns presentation only; world geometry comes from grid-view. */\nexport interface GridViewProps extends ZoraBaseProps {\n items: readonly GridRectItem[];\n contentWidth: number;\n contentHeight: number;\n width: number;\n height: number;\n zoom?: number;\n overscanPixels?: number;\n onViewportChange?: (viewport: GridViewport) => void;\n renderItem: (item: GridRectItem) => React.ReactNode;\n}\n\n/** An item needs a stable ID; visual positioning belongs to the TileGrid composition. */\nexport interface TileGridItem {\n readonly id: string;\n}\n\n/** Reusable tile presentation based on the generic world/viewport engine. */\nexport interface TileGridProps extends ZoraBaseProps {\n items: readonly TileGridItem[];\n width?: number;\n height?: number;\n tileSize?: number;\n gap?: number;\n zoom?: number;\n overscanPixels?: number;\n renderItem: (item: TileGridItem) => React.ReactNode;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"grid-view.js","sourceRoot":"","sources":["../../src/types/grid-view.ts"],"names":[],"mappings":"","sourcesContent":["import type { GridRectItem, GridViewport } from '@ankhorage/grid-view';\nimport type React from 'react';\n\nimport type { ZoraBaseProps } from './base';\n\n/** The renderer owns presentation only; world geometry comes from grid-view. */\nexport interface GridViewProps extends ZoraBaseProps {\n items: readonly GridRectItem[];\n contentWidth: number;\n contentHeight: number;\n width: number;\n height: number;\n zoom?: number;\n overscanPixels?: number;\n /** Stable item identity that must be scrolled into view without duplicating viewport geometry. */\n focusedItemId?: string;\n /** Pixel inset retained around a revealed focused item. */\n revealPaddingPixels?: number;\n onViewportChange?: (viewport: GridViewport) => void;\n onVisibleItemIdsChange?: (ids: readonly string[]) => void;\n renderItem: (item: GridRectItem) => React.ReactNode;\n}\n\n/** An item needs a stable ID; visual positioning belongs to the TileGrid composition. */\nexport interface TileGridItem {\n readonly id: string;\n}\n\n/** Reusable tile presentation based on the generic world/viewport engine. */\nexport interface TileGridProps extends ZoraBaseProps {\n items: readonly TileGridItem[];\n width?: number;\n height?: number;\n tileSize?: number;\n gap?: number;\n zoom?: number;\n overscanPixels?: number;\n focusedItemId?: string;\n revealPaddingPixels?: number;\n /** Reports the same measured column count used to position tiles. */\n onColumnsChange?: (columns: number) => void;\n onVisibleItemIdsChange?: (ids: readonly string[]) => void;\n renderItem: (item: TileGridItem) => React.ReactNode;\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ankhorage/zora",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "25.
|
|
4
|
+
"version": "25.1.0",
|
|
5
5
|
"description": "Opinionated React Native and React Native Web UI kit built on @ankhorage/surface.",
|
|
6
6
|
"homepage": "https://github.com/ankhorage/zora#readme",
|
|
7
7
|
"bugs": {
|
|
@@ -44,9 +44,9 @@
|
|
|
44
44
|
"@ankhorage/color-theory": "^0.3.1",
|
|
45
45
|
"@ankhorage/contracts": "^24.2.1",
|
|
46
46
|
"@ankhorage/game": "^0.1.2",
|
|
47
|
-
"@ankhorage/grid-view": "^0.
|
|
47
|
+
"@ankhorage/grid-view": "^0.2.0",
|
|
48
48
|
"@ankhorage/reader": "^0.1.0",
|
|
49
|
-
"@ankhorage/runtime": "^4.0.
|
|
49
|
+
"@ankhorage/runtime": "^4.0.7",
|
|
50
50
|
"@ankhorage/surface": "^9.4.2",
|
|
51
51
|
"@ankhorage/utility": "^1.9.85",
|
|
52
52
|
"chess.js": "^1.4.0",
|
|
@@ -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,8 @@ 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 { ExplorerKeyboardProxy } from './ExplorerKeyboardProxy';
|
|
13
|
+
import { useExplorerKeyboardFocus } from './useExplorerKeyboardFocus';
|
|
12
14
|
|
|
13
15
|
/*** Single canonical Explorer implementation for media and file catalogue presentation. */
|
|
14
16
|
export const Explorer = withZoraThemeScope(ExplorerInner);
|
|
@@ -37,9 +39,14 @@ function ExplorerInner({
|
|
|
37
39
|
const [internalSelectedIds, setInternalSelectedIds] =
|
|
38
40
|
React.useState<readonly string[]>(defaultSelectedIds);
|
|
39
41
|
const anchorId = React.useRef<string | null>(null);
|
|
42
|
+
const [columns, setColumns] = React.useState(1);
|
|
43
|
+
const [visibleIds, setVisibleIds] = React.useState<readonly string[]>([]);
|
|
40
44
|
const effectiveSelectedIds = selectedIds ?? internalSelectedIds;
|
|
41
45
|
const itemLookup = React.useMemo(() => new Map(items.map((item) => [item.id, item])), [items]);
|
|
42
|
-
const ids =
|
|
46
|
+
const ids = React.useMemo(
|
|
47
|
+
() => items.filter((item) => !item.disabled).map((item) => item.id),
|
|
48
|
+
[items],
|
|
49
|
+
);
|
|
43
50
|
const selected = new Set(effectiveSelectedIds);
|
|
44
51
|
const passive = interactionPolicy === 'passive' || disabled || readOnly;
|
|
45
52
|
|
|
@@ -58,53 +65,76 @@ function ExplorerInner({
|
|
|
58
65
|
onSelectionChange?.({ selectedIds: next });
|
|
59
66
|
};
|
|
60
67
|
|
|
68
|
+
const keyboard = useExplorerKeyboardFocus(
|
|
69
|
+
items,
|
|
70
|
+
columns,
|
|
71
|
+
passive,
|
|
72
|
+
visibleIds,
|
|
73
|
+
(targetId, originId, shiftKey) => {
|
|
74
|
+
if (!shiftKey) return;
|
|
75
|
+
anchorId.current ??= originId;
|
|
76
|
+
const nextItem = itemLookup.get(targetId);
|
|
77
|
+
if (nextItem) select(nextItem, 'range');
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
|
|
61
81
|
const renderTile = (tile: { readonly id: string }) => {
|
|
62
82
|
const item = itemLookup.get(tile.id);
|
|
63
83
|
if (!item) return null;
|
|
64
84
|
const isSelected = selected.has(item.id);
|
|
65
85
|
const canInteract = !passive && !item.disabled;
|
|
66
86
|
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}`}
|
|
87
|
+
<ExplorerKeyboardProxy
|
|
88
|
+
onKeyDown={(key, shiftKey) => keyboard.onKeyDown(item.id, key, shiftKey)}
|
|
91
89
|
>
|
|
92
|
-
<
|
|
93
|
-
{item.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
90
|
+
<NativePressable
|
|
91
|
+
accessibilityLabel={item.name}
|
|
92
|
+
accessibilityRole="button"
|
|
93
|
+
accessibilityState={{ selected: isSelected, disabled: !canInteract }}
|
|
94
|
+
disabled={!canInteract}
|
|
95
|
+
focusable={canInteract && (Platform.OS !== 'web' || item.id === keyboard.tabStopId)}
|
|
96
|
+
onFocus={() => keyboard.onFocus(item.id)}
|
|
97
|
+
onLongPress={() => select(item, 'toggle')}
|
|
98
|
+
onPress={(event) => {
|
|
99
|
+
const intent = resolveExplorerPressIntent(event.nativeEvent);
|
|
100
|
+
select(item, intent);
|
|
101
|
+
if (selectionMode === 'single' && intent === 'replace') {
|
|
102
|
+
onActivate?.({ id: item.id });
|
|
103
|
+
}
|
|
104
|
+
}}
|
|
105
|
+
style={{
|
|
106
|
+
flex: 1,
|
|
107
|
+
overflow: 'hidden',
|
|
108
|
+
borderRadius: 8,
|
|
109
|
+
borderWidth: isSelected ? 2 : 1,
|
|
110
|
+
borderColor: isSelected ? theme.colors.primary : theme.semantics.neutral.divider,
|
|
111
|
+
backgroundColor: theme.semantics.neutral.surface,
|
|
112
|
+
padding: 8,
|
|
113
|
+
gap: 4,
|
|
114
|
+
}}
|
|
115
|
+
ref={(node) => keyboard.registerTile(item.id, node)}
|
|
116
|
+
testID={`explorer-item-${item.id}`}
|
|
117
|
+
>
|
|
118
|
+
<NativeView style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
|
119
|
+
{item.thumbnailUri ? (
|
|
120
|
+
<Image
|
|
121
|
+
accessibilityLabel={item.name}
|
|
122
|
+
aspectRatio={1}
|
|
123
|
+
source={item.thumbnailUri}
|
|
124
|
+
width="100%"
|
|
125
|
+
/>
|
|
126
|
+
) : (
|
|
127
|
+
<Icon
|
|
128
|
+
name={item.kind === 'folder' ? 'folder-outline' : 'document-outline'}
|
|
129
|
+
size={32}
|
|
130
|
+
/>
|
|
131
|
+
)}
|
|
132
|
+
</NativeView>
|
|
133
|
+
<Text numberOfLines={1} variant="caption">
|
|
134
|
+
{item.name}
|
|
135
|
+
</Text>
|
|
136
|
+
</NativePressable>
|
|
137
|
+
</ExplorerKeyboardProxy>
|
|
108
138
|
);
|
|
109
139
|
};
|
|
110
140
|
|
|
@@ -114,8 +144,11 @@ function ExplorerInner({
|
|
|
114
144
|
|
|
115
145
|
return (
|
|
116
146
|
<TileGrid
|
|
147
|
+
focusedItemId={keyboard.focusedId ?? undefined}
|
|
117
148
|
height={height}
|
|
118
149
|
interactionPolicy={interactionPolicy}
|
|
150
|
+
onColumnsChange={setColumns}
|
|
151
|
+
onVisibleItemIdsChange={setVisibleIds}
|
|
119
152
|
items={items}
|
|
120
153
|
renderItem={renderTile}
|
|
121
154
|
testID={testID}
|
|
@@ -126,7 +159,10 @@ function ExplorerInner({
|
|
|
126
159
|
);
|
|
127
160
|
}
|
|
128
161
|
|
|
129
|
-
/***
|
|
162
|
+
/***
|
|
163
|
+
* Normalize platform press modifiers into selection intents
|
|
164
|
+
* without passing event objects into the public contracts.
|
|
165
|
+
*/
|
|
130
166
|
function resolveExplorerPressIntent(event: unknown): 'replace' | 'toggle' | 'range' {
|
|
131
167
|
if (typeof event !== 'object' || event === null) return 'replace';
|
|
132
168
|
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
|
+
}
|