@ankhorage/zora 25.0.1 → 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.
Files changed (63) hide show
  1. package/CHANGELOG.md +6 -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 +30 -13
  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/public.d.ts +1 -0
  27. package/dist/features/explorer/public.d.ts.map +1 -1
  28. package/dist/features/explorer/public.js +1 -0
  29. package/dist/features/explorer/public.js.map +1 -1
  30. package/dist/features/grid-view/adapters/inbound/GridView.d.ts +1 -1
  31. package/dist/features/grid-view/adapters/inbound/GridView.d.ts.map +1 -1
  32. package/dist/features/grid-view/adapters/inbound/GridView.js +36 -4
  33. package/dist/features/grid-view/adapters/inbound/GridView.js.map +1 -1
  34. package/dist/features/grid-view/adapters/inbound/TileGrid.d.ts +1 -1
  35. package/dist/features/grid-view/adapters/inbound/TileGrid.d.ts.map +1 -1
  36. package/dist/features/grid-view/adapters/inbound/TileGrid.js +7 -3
  37. package/dist/features/grid-view/adapters/inbound/TileGrid.js.map +1 -1
  38. package/dist/types/grid-view.d.ts +10 -0
  39. package/dist/types/grid-view.d.ts.map +1 -1
  40. package/dist/types/grid-view.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/features/explorer/adapters/inbound/Explorer.tsx +79 -43
  43. package/src/features/explorer/adapters/inbound/ExplorerKeyboardProxy.native.tsx +11 -0
  44. package/src/features/explorer/adapters/inbound/ExplorerKeyboardProxy.tsx +11 -0
  45. package/src/features/explorer/adapters/inbound/ExplorerKeyboardProxy.web.tsx +20 -0
  46. package/src/features/explorer/adapters/inbound/useExplorerKeyboardFocus.test.tsx +232 -0
  47. package/src/features/explorer/adapters/inbound/useExplorerKeyboardFocus.ts +77 -0
  48. package/src/features/explorer/application/getExplorerNextFocusId.test.ts +37 -0
  49. package/src/features/explorer/application/getExplorerNextFocusId.ts +61 -0
  50. package/src/features/explorer/public.ts +1 -0
  51. package/src/features/grid-view/adapters/inbound/GridView.tsx +49 -1
  52. package/src/features/grid-view/adapters/inbound/TileGrid.tsx +11 -0
  53. package/src/types/grid-view.ts +10 -0
  54. package/web-dist/chunks/{Accordion-b20zfsfz.js → Accordion-54k0ncs3.js} +83 -0
  55. package/web-dist/chunks/Accordion-fmn31fgf.js +321 -0
  56. package/web-dist/components/explorer/Explorer.js +2 -2
  57. package/web-dist/components/file-explorer/FileExplorer.js +2 -2
  58. package/web-dist/components/grid-view/GridView.js +1 -1
  59. package/web-dist/components/media-explorer/MediaExplorer.js +2 -2
  60. package/web-dist/components/tile-grid/TileGrid.js +1 -1
  61. package/web-dist/components/uploader/Uploader.js +2 -2
  62. package/web-dist/manifest.json +10 -10
  63. package/web-dist/chunks/Accordion-0md8wbtx.js +0 -197
@@ -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,qBAiCf"}
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.0.1",
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": {
@@ -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 = items.filter((item) => !item.disabled).map((item) => item.id);
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
- <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}`}
87
+ <ExplorerKeyboardProxy
88
+ onKeyDown={(key, shiftKey) => keyboard.onKeyDown(item.id, key, shiftKey)}
91
89
  >
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>
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
- /*** Normalize platform press modifiers for multi-selection without passing event objects into contracts. */
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
+ }