@slithy/react-grid-gallery 0.1.1
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/README.md +214 -0
- package/dist/index.d.ts +70 -0
- package/dist/index.js +307 -0
- package/package.json +50 -0
package/README.md
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# @slithy/react-grid-gallery
|
|
2
|
+
|
|
3
|
+
React grid gallery component. Fixed columns, uniform cell size, optional virtualization and keyboard navigation.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @slithy/react-grid-gallery
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
**Peer dependencies:** `react@^17 || ^18 || ^19`
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## `GridGallery`
|
|
16
|
+
|
|
17
|
+
Drop-in component. Pass items, tell it how to render each one.
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
import { GridGallery } from '@slithy/react-grid-gallery'
|
|
21
|
+
|
|
22
|
+
type Photo = { src: string; alt: string }
|
|
23
|
+
|
|
24
|
+
const items = photos.map((p, i) => ({ ...p, key: i }))
|
|
25
|
+
|
|
26
|
+
<GridGallery
|
|
27
|
+
items={items}
|
|
28
|
+
columns={4}
|
|
29
|
+
gap={8}
|
|
30
|
+
aspectRatio={1}
|
|
31
|
+
renderItem={(item, { loaded, focused }, { onLoad, onError }) => (
|
|
32
|
+
<img
|
|
33
|
+
src={item.src}
|
|
34
|
+
alt={item.alt}
|
|
35
|
+
onLoad={onLoad}
|
|
36
|
+
onError={onError}
|
|
37
|
+
style={{ width: '100%', height: '100%', objectFit: 'cover', opacity: loaded ? 1 : 0 }}
|
|
38
|
+
/>
|
|
39
|
+
)}
|
|
40
|
+
/>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Props
|
|
44
|
+
|
|
45
|
+
| Prop | Type | Default | Description |
|
|
46
|
+
|---|---|---|---|
|
|
47
|
+
| `items` | `GalleryItem<T>[]` | — | Items to display. Each must have a `key` property. |
|
|
48
|
+
| `renderItem` | `(item, layout, handlers) => ReactNode` | — | Render function for each cell. See [renderItem](#renderitem). |
|
|
49
|
+
| `scrollContainerRef` | `ScrollContainerRef` | — | Scroll container for virtualization. See [Virtualization](#virtualization). |
|
|
50
|
+
| `columns` | `number \| (width) => number` | — | Column count. Pass a function for responsive columns. |
|
|
51
|
+
| `gap` | `number \| (width) => number` | `0` | Gap between cells in pixels. |
|
|
52
|
+
| `aspectRatio` | `number \| (width) => number` | `1` | Cell width ÷ height. `1` = square, `1.5` = landscape. |
|
|
53
|
+
| `padding` | `number` | `0` | Padding around the grid in pixels. |
|
|
54
|
+
| `virtualize` | `boolean` | `false` | Enable virtual rendering. Only renders visible rows. |
|
|
55
|
+
| `overscan` | `number` | `cellHeight * 4` | Extra pixels to render above and below the viewport. |
|
|
56
|
+
| `navigable` | `boolean` | `false` | Enable keyboard navigation with ARIA grid semantics. |
|
|
57
|
+
| `onActivate` | `(index, shiftKey) => void` | — | Called when a cell is activated via Space or Enter. |
|
|
58
|
+
|
|
59
|
+
### `renderItem`
|
|
60
|
+
|
|
61
|
+
The render function receives three arguments:
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
renderItem(
|
|
65
|
+
item, // GalleryItem<T>: your data + key
|
|
66
|
+
{ loaded, focused }, // layout state
|
|
67
|
+
{ onLoad, onError }, // pass to <img> (or equivalent)
|
|
68
|
+
)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- `loaded` — `true` after `onLoad` fires; use to show a placeholder or fade in
|
|
72
|
+
- `focused` — `true` when this cell has keyboard focus (only meaningful when `navigable` is set)
|
|
73
|
+
- `onLoad` / `onError` — wire to your image element so the gallery tracks load state
|
|
74
|
+
|
|
75
|
+
### Responsive columns
|
|
76
|
+
|
|
77
|
+
Pass a function to `columns` (and `gap`) to change layout at different widths:
|
|
78
|
+
|
|
79
|
+
```tsx
|
|
80
|
+
<GridGallery
|
|
81
|
+
items={items}
|
|
82
|
+
columns={(w) => (w < 600 ? 2 : w < 1000 ? 3 : 4)}
|
|
83
|
+
gap={(w) => (w < 600 ? 4 : 8)}
|
|
84
|
+
aspectRatio={1}
|
|
85
|
+
renderItem={...}
|
|
86
|
+
/>
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Virtualization
|
|
92
|
+
|
|
93
|
+
Enable with `virtualize`. Only rows near the viewport are rendered — the rest are replaced by spacer elements.
|
|
94
|
+
|
|
95
|
+
```tsx
|
|
96
|
+
const scrollRef = useRef<HTMLDivElement>(null)
|
|
97
|
+
|
|
98
|
+
<div ref={scrollRef} style={{ height: 600, overflowY: 'auto' }}>
|
|
99
|
+
<GridGallery
|
|
100
|
+
items={items}
|
|
101
|
+
columns={4}
|
|
102
|
+
gap={8}
|
|
103
|
+
aspectRatio={1}
|
|
104
|
+
virtualize
|
|
105
|
+
scrollContainerRef={scrollRef}
|
|
106
|
+
renderItem={...}
|
|
107
|
+
/>
|
|
108
|
+
</div>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
When no `scrollContainerRef` is provided, the gallery virtualizes against the window.
|
|
112
|
+
|
|
113
|
+
`overscan` controls how much extra content to render beyond the visible area (default: 4× cell height). Increase it on slower devices to reduce blank-row flicker.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Keyboard navigation
|
|
118
|
+
|
|
119
|
+
Set `navigable` to add ARIA grid semantics and arrow-key navigation:
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
<GridGallery
|
|
123
|
+
items={items}
|
|
124
|
+
columns={4}
|
|
125
|
+
aspectRatio={1}
|
|
126
|
+
navigable
|
|
127
|
+
onActivate={(index, shiftKey) => openLightbox(index, shiftKey)}
|
|
128
|
+
renderItem={(item, { focused }, handlers) => (
|
|
129
|
+
<img
|
|
130
|
+
src={item.src}
|
|
131
|
+
style={{ outline: focused ? '2px solid blue' : 'none' }}
|
|
132
|
+
{...handlers}
|
|
133
|
+
/>
|
|
134
|
+
)}
|
|
135
|
+
/>
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
| Key | Action |
|
|
139
|
+
|---|---|
|
|
140
|
+
| `ArrowRight` / `ArrowLeft` | Move one cell right / left |
|
|
141
|
+
| `ArrowDown` / `ArrowUp` | Move one row down / up |
|
|
142
|
+
| `Home` | First cell in row (`Ctrl+Home`: first cell) |
|
|
143
|
+
| `End` | Last cell in row (`Ctrl+End`: last cell) |
|
|
144
|
+
| `Space` / `Enter` | Activate cell (`onActivate`) |
|
|
145
|
+
|
|
146
|
+
Arrow keys with `Meta` held are ignored (allows OS / browser shortcuts to pass through).
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## `useGridGallery`
|
|
151
|
+
|
|
152
|
+
Headless hook for building a custom renderer. Returns the same layout state `GridGallery` uses internally.
|
|
153
|
+
|
|
154
|
+
```tsx
|
|
155
|
+
import { useGridGallery } from '@slithy/react-grid-gallery'
|
|
156
|
+
|
|
157
|
+
const {
|
|
158
|
+
containerRef,
|
|
159
|
+
rows,
|
|
160
|
+
cellWidth,
|
|
161
|
+
cellHeight,
|
|
162
|
+
gap,
|
|
163
|
+
columns,
|
|
164
|
+
onLoad,
|
|
165
|
+
onError,
|
|
166
|
+
virtualWindow,
|
|
167
|
+
focusedIndex,
|
|
168
|
+
handleItemFocus,
|
|
169
|
+
handleItemKeyDown,
|
|
170
|
+
} = useGridGallery(items, options, scrollContainerRef)
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`virtualWindow` is `null` when virtualization is off; otherwise `{ firstIndex, lastIndex, topSpacerHeight, bottomSpacerHeight }`.
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## `computeGridLayout`
|
|
178
|
+
|
|
179
|
+
Lower-level layout utility. Takes items, column count, and cell dimensions; returns rows.
|
|
180
|
+
|
|
181
|
+
```tsx
|
|
182
|
+
import { computeGridLayout } from '@slithy/react-grid-gallery'
|
|
183
|
+
|
|
184
|
+
const rows = computeGridLayout(items, columns, cellWidth, cellHeight)
|
|
185
|
+
// rows: GridLayoutRow<T>[]
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Types
|
|
191
|
+
|
|
192
|
+
```tsx
|
|
193
|
+
import type {
|
|
194
|
+
GalleryItem,
|
|
195
|
+
GridOptions,
|
|
196
|
+
GridRow,
|
|
197
|
+
GridLayoutRow,
|
|
198
|
+
ScrollContainerRef,
|
|
199
|
+
} from '@slithy/react-grid-gallery'
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
**`GalleryItem<T>`** — Your item type with a required `key`:
|
|
203
|
+
|
|
204
|
+
```tsx
|
|
205
|
+
type GalleryItem<T> = T & { key: string | number }
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
**`GridOptions`** — All options accepted by `GridGallery` and `useGridGallery`.
|
|
209
|
+
|
|
210
|
+
**`ScrollContainerRef`** — `RefObject<HTMLElement | null> | HTMLElement | null`
|
|
211
|
+
|
|
212
|
+
**`GridRow<T>`** — A rendered row with per-item `loaded` state.
|
|
213
|
+
|
|
214
|
+
**`GridLayoutRow<T>`** — A computed layout row (output of `computeGridLayout`).
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import React, { RefObject, ReactEventHandler, ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
type ScrollContainerRef = RefObject<HTMLElement | null> | HTMLElement | null;
|
|
4
|
+
type GridOptions = {
|
|
5
|
+
columns: number | ((containerWidth: number) => number);
|
|
6
|
+
gap?: number | ((containerWidth: number) => number);
|
|
7
|
+
aspectRatio?: number | ((containerWidth: number) => number);
|
|
8
|
+
padding?: number;
|
|
9
|
+
virtualize?: boolean;
|
|
10
|
+
overscan?: number;
|
|
11
|
+
navigable?: boolean;
|
|
12
|
+
focusedIndex?: number;
|
|
13
|
+
onFocusedIndexChange?: (index: number) => void;
|
|
14
|
+
onActivate?: (index: number, shiftKey: boolean) => void;
|
|
15
|
+
};
|
|
16
|
+
type GalleryItem<T> = T & {
|
|
17
|
+
key: string | number;
|
|
18
|
+
};
|
|
19
|
+
type GridLayoutRow<T> = {
|
|
20
|
+
items: GalleryItem<T>[];
|
|
21
|
+
width: number;
|
|
22
|
+
height: number;
|
|
23
|
+
};
|
|
24
|
+
type GridRow<T> = {
|
|
25
|
+
items: Array<{
|
|
26
|
+
item: GalleryItem<T>;
|
|
27
|
+
width: number;
|
|
28
|
+
height: number;
|
|
29
|
+
loaded: boolean;
|
|
30
|
+
}>;
|
|
31
|
+
height: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
declare function computeGridLayout<T>(items: GalleryItem<T>[], columns: number, cellWidth: number, cellHeight: number): GridLayoutRow<T>[];
|
|
35
|
+
|
|
36
|
+
type VirtualWindow = {
|
|
37
|
+
firstIndex: number;
|
|
38
|
+
lastIndex: number;
|
|
39
|
+
topSpacerHeight: number;
|
|
40
|
+
bottomSpacerHeight: number;
|
|
41
|
+
};
|
|
42
|
+
declare function useGridGallery<T>(items: GalleryItem<T>[], options: GridOptions, scrollContainerRef?: ScrollContainerRef): {
|
|
43
|
+
containerRef: RefObject<HTMLDivElement | null>;
|
|
44
|
+
rows: GridRow<T>[];
|
|
45
|
+
cellWidth: number;
|
|
46
|
+
cellHeight: number;
|
|
47
|
+
gap: number;
|
|
48
|
+
columns: number;
|
|
49
|
+
onLoad: (key: string | number) => void;
|
|
50
|
+
onError: (key: string | number) => void;
|
|
51
|
+
virtualWindow: VirtualWindow | null;
|
|
52
|
+
focusedIndex: number;
|
|
53
|
+
handleItemFocus: (index: number) => void;
|
|
54
|
+
handleItemKeyDown: (itemIndex: number, e: React.KeyboardEvent) => void;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type Props<T> = {
|
|
58
|
+
items: GalleryItem<T>[];
|
|
59
|
+
renderItem: (item: GalleryItem<T>, layout: {
|
|
60
|
+
loaded: boolean;
|
|
61
|
+
focused: boolean;
|
|
62
|
+
}, handlers: {
|
|
63
|
+
onLoad: ReactEventHandler<HTMLImageElement>;
|
|
64
|
+
onError: ReactEventHandler<HTMLImageElement>;
|
|
65
|
+
}) => ReactNode;
|
|
66
|
+
scrollContainerRef?: ScrollContainerRef;
|
|
67
|
+
} & GridOptions;
|
|
68
|
+
declare function GridGallery<T>({ items, renderItem, scrollContainerRef, ...options }: Props<T>): ReactNode;
|
|
69
|
+
|
|
70
|
+
export { type GalleryItem, GridGallery, type GridLayoutRow, type GridOptions, type GridRow, type ScrollContainerRef, computeGridLayout, useGridGallery };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// src/computeGridLayout.ts
|
|
2
|
+
function computeGridLayout(items, columns, cellWidth, cellHeight) {
|
|
3
|
+
if (items.length === 0 || columns <= 0 || cellWidth <= 0) return [];
|
|
4
|
+
const rows = [];
|
|
5
|
+
for (let i = 0; i < items.length; i += columns) {
|
|
6
|
+
rows.push({
|
|
7
|
+
items: items.slice(i, i + columns),
|
|
8
|
+
width: cellWidth,
|
|
9
|
+
height: cellHeight
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
return rows;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// src/useGridGallery.ts
|
|
16
|
+
import { useCallback, useEffect as useEffect2, useLayoutEffect, useMemo, useRef as useRef2, useState as useState2 } from "react";
|
|
17
|
+
|
|
18
|
+
// src/useVirtualWindow.ts
|
|
19
|
+
import { useEffect, useRef, useState } from "react";
|
|
20
|
+
function resolveScrollEl(ref) {
|
|
21
|
+
if (ref == null) return null;
|
|
22
|
+
if ("current" in ref) return ref.current;
|
|
23
|
+
return ref;
|
|
24
|
+
}
|
|
25
|
+
function useVirtualWindow(containerRef, enabled, scrollContainerRef) {
|
|
26
|
+
const [range, setRange] = useState(null);
|
|
27
|
+
const rafIdRef = useRef(null);
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!enabled) return;
|
|
30
|
+
const update = () => {
|
|
31
|
+
const el = containerRef.current;
|
|
32
|
+
if (!el) return;
|
|
33
|
+
const rect = el.getBoundingClientRect();
|
|
34
|
+
const sc = resolveScrollEl(scrollContainerRef);
|
|
35
|
+
if (sc) {
|
|
36
|
+
const scRect = sc.getBoundingClientRect();
|
|
37
|
+
const containerTop = 0 - (rect.top - scRect.top);
|
|
38
|
+
setRange({ top: containerTop, bottom: containerTop + sc.clientHeight });
|
|
39
|
+
} else {
|
|
40
|
+
const containerTop = 0 - rect.top;
|
|
41
|
+
setRange({ top: containerTop, bottom: containerTop + window.innerHeight });
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const handleScroll = () => {
|
|
45
|
+
if (rafIdRef.current !== null) cancelAnimationFrame(rafIdRef.current);
|
|
46
|
+
rafIdRef.current = requestAnimationFrame(() => {
|
|
47
|
+
update();
|
|
48
|
+
rafIdRef.current = null;
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
update();
|
|
52
|
+
const target = resolveScrollEl(scrollContainerRef) ?? window;
|
|
53
|
+
target.addEventListener("scroll", handleScroll, { passive: true });
|
|
54
|
+
if (target === window) {
|
|
55
|
+
window.addEventListener("resize", handleScroll, { passive: true });
|
|
56
|
+
}
|
|
57
|
+
const ro = new ResizeObserver(update);
|
|
58
|
+
if (target !== window) ro.observe(target);
|
|
59
|
+
return () => {
|
|
60
|
+
target.removeEventListener("scroll", handleScroll);
|
|
61
|
+
if (target === window) window.removeEventListener("resize", handleScroll);
|
|
62
|
+
if (rafIdRef.current !== null) cancelAnimationFrame(rafIdRef.current);
|
|
63
|
+
ro.disconnect();
|
|
64
|
+
};
|
|
65
|
+
}, [enabled, containerRef, scrollContainerRef]);
|
|
66
|
+
return enabled ? range : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/useGridGallery.ts
|
|
70
|
+
function useGridGallery(items, options, scrollContainerRef) {
|
|
71
|
+
const containerRef = useRef2(null);
|
|
72
|
+
const [containerWidth, setContainerWidth] = useState2(0);
|
|
73
|
+
const [focusedIndex, setFocusedIndex] = useState2(0);
|
|
74
|
+
const pendingFocusRef = useRef2(null);
|
|
75
|
+
const loadedSet = useRef2(/* @__PURE__ */ new Set());
|
|
76
|
+
const [loadedVersion, setLoadedVersion] = useState2(0);
|
|
77
|
+
const virtualRange = useVirtualWindow(containerRef, options.virtualize === true, scrollContainerRef);
|
|
78
|
+
useEffect2(() => {
|
|
79
|
+
const observer = new ResizeObserver((entries) => {
|
|
80
|
+
const width = entries[0]?.contentRect.width ?? 0;
|
|
81
|
+
if (width > 0) setContainerWidth(width);
|
|
82
|
+
});
|
|
83
|
+
const el = containerRef.current;
|
|
84
|
+
if (el) observer.observe(el);
|
|
85
|
+
return () => observer.disconnect();
|
|
86
|
+
}, []);
|
|
87
|
+
const onLoad = useCallback((key) => {
|
|
88
|
+
if (!loadedSet.current.has(key)) {
|
|
89
|
+
loadedSet.current.add(key);
|
|
90
|
+
setLoadedVersion((v) => v + 1);
|
|
91
|
+
}
|
|
92
|
+
}, []);
|
|
93
|
+
const onError = useCallback((_key) => {
|
|
94
|
+
}, []);
|
|
95
|
+
const resolvedColumns = Math.max(
|
|
96
|
+
1,
|
|
97
|
+
Math.round(typeof options.columns === "function" ? options.columns(containerWidth) : options.columns)
|
|
98
|
+
);
|
|
99
|
+
const resolvedGap = typeof options.gap === "function" ? options.gap(containerWidth) : options.gap ?? 0;
|
|
100
|
+
const resolvedAspectRatio = typeof options.aspectRatio === "function" ? options.aspectRatio(containerWidth) : options.aspectRatio ?? 1;
|
|
101
|
+
const cellWidth = containerWidth > 0 ? Math.floor((containerWidth - resolvedGap * (resolvedColumns - 1)) / resolvedColumns) : 0;
|
|
102
|
+
const cellHeight = cellWidth > 0 ? Math.round(cellWidth / resolvedAspectRatio) : 0;
|
|
103
|
+
const rows = useMemo(() => {
|
|
104
|
+
if (cellWidth === 0) return [];
|
|
105
|
+
const layoutRows = computeGridLayout(items, resolvedColumns, cellWidth, cellHeight);
|
|
106
|
+
return layoutRows.map((row) => ({
|
|
107
|
+
height: row.height,
|
|
108
|
+
items: row.items.map((item) => ({
|
|
109
|
+
item,
|
|
110
|
+
width: row.width,
|
|
111
|
+
height: row.height,
|
|
112
|
+
loaded: loadedSet.current.has(item.key)
|
|
113
|
+
}))
|
|
114
|
+
}));
|
|
115
|
+
}, [items, resolvedColumns, cellWidth, cellHeight, loadedVersion]);
|
|
116
|
+
const rowStride = cellHeight + resolvedGap;
|
|
117
|
+
let virtualWindow = null;
|
|
118
|
+
if (options.virtualize && virtualRange !== null && rows.length > 0) {
|
|
119
|
+
const totalRows = rows.length;
|
|
120
|
+
const overscan = options.overscan ?? cellHeight * 4;
|
|
121
|
+
const visibleTop = virtualRange.top - overscan;
|
|
122
|
+
const visibleBottom = virtualRange.bottom + overscan;
|
|
123
|
+
let firstIndex = Math.max(0, Math.floor(visibleTop / rowStride));
|
|
124
|
+
let lastIndex = Math.min(totalRows - 1, Math.ceil(visibleBottom / rowStride) - 1);
|
|
125
|
+
if (firstIndex > lastIndex) {
|
|
126
|
+
firstIndex = 0;
|
|
127
|
+
lastIndex = totalRows - 1;
|
|
128
|
+
}
|
|
129
|
+
const topSpacerHeight = firstIndex * rowStride;
|
|
130
|
+
const bottomSpacerHeight = (totalRows - 1 - lastIndex) * rowStride;
|
|
131
|
+
virtualWindow = { firstIndex, lastIndex, topSpacerHeight, bottomSpacerHeight };
|
|
132
|
+
}
|
|
133
|
+
const isControlled = options.focusedIndex !== void 0;
|
|
134
|
+
const padding = options.padding ?? 0;
|
|
135
|
+
function scrollToRow(rowIndex) {
|
|
136
|
+
const rowTop = padding + rowIndex * rowStride;
|
|
137
|
+
const rowBottom = rowTop + cellHeight;
|
|
138
|
+
const scrollEl = resolveScrollEl(scrollContainerRef);
|
|
139
|
+
if (scrollEl) {
|
|
140
|
+
if (rowTop < scrollEl.scrollTop) {
|
|
141
|
+
scrollEl.scrollTop = rowTop;
|
|
142
|
+
} else if (rowBottom > scrollEl.scrollTop + scrollEl.clientHeight) {
|
|
143
|
+
scrollEl.scrollTop = rowBottom - scrollEl.clientHeight;
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
const containerEl = containerRef.current;
|
|
147
|
+
if (!containerEl) return;
|
|
148
|
+
const absTop = containerEl.getBoundingClientRect().top + window.scrollY + rowTop;
|
|
149
|
+
const absBottom = absTop + cellHeight;
|
|
150
|
+
if (absTop < window.scrollY) {
|
|
151
|
+
window.scrollTo({ top: absTop });
|
|
152
|
+
} else if (absBottom > window.scrollY + window.innerHeight) {
|
|
153
|
+
window.scrollTo({ top: absBottom - window.innerHeight });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function navigateTo(newIndex) {
|
|
158
|
+
if (items.length === 0) return;
|
|
159
|
+
const clamped = Math.max(0, Math.min(newIndex, items.length - 1));
|
|
160
|
+
if (!isControlled) setFocusedIndex(clamped);
|
|
161
|
+
options.onFocusedIndexChange?.(clamped);
|
|
162
|
+
const target = containerRef.current?.querySelector(`[data-grid-index="${clamped}"]`);
|
|
163
|
+
if (target) {
|
|
164
|
+
target.focus();
|
|
165
|
+
} else {
|
|
166
|
+
scrollToRow(Math.floor(clamped / resolvedColumns));
|
|
167
|
+
pendingFocusRef.current = clamped;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function handleItemKeyDown(itemIndex, e) {
|
|
171
|
+
const col = itemIndex % resolvedColumns;
|
|
172
|
+
const rowStart = itemIndex - col;
|
|
173
|
+
const rowEnd = Math.min(rowStart + resolvedColumns - 1, items.length - 1);
|
|
174
|
+
switch (e.key) {
|
|
175
|
+
case "ArrowRight":
|
|
176
|
+
if (e.metaKey) break;
|
|
177
|
+
e.preventDefault();
|
|
178
|
+
navigateTo(itemIndex + 1);
|
|
179
|
+
break;
|
|
180
|
+
case "ArrowLeft":
|
|
181
|
+
if (e.metaKey) break;
|
|
182
|
+
e.preventDefault();
|
|
183
|
+
navigateTo(itemIndex - 1);
|
|
184
|
+
break;
|
|
185
|
+
case "ArrowDown":
|
|
186
|
+
if (e.metaKey) break;
|
|
187
|
+
e.preventDefault();
|
|
188
|
+
navigateTo(itemIndex + resolvedColumns);
|
|
189
|
+
break;
|
|
190
|
+
case "ArrowUp":
|
|
191
|
+
if (e.metaKey) break;
|
|
192
|
+
e.preventDefault();
|
|
193
|
+
navigateTo(itemIndex - resolvedColumns);
|
|
194
|
+
break;
|
|
195
|
+
case "Home":
|
|
196
|
+
e.preventDefault();
|
|
197
|
+
navigateTo(e.ctrlKey ? 0 : rowStart);
|
|
198
|
+
break;
|
|
199
|
+
case "End":
|
|
200
|
+
e.preventDefault();
|
|
201
|
+
navigateTo(e.ctrlKey ? items.length - 1 : rowEnd);
|
|
202
|
+
break;
|
|
203
|
+
case " ":
|
|
204
|
+
case "Enter":
|
|
205
|
+
e.preventDefault();
|
|
206
|
+
options.onActivate?.(itemIndex, e.shiftKey);
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
useLayoutEffect(() => {
|
|
211
|
+
if (pendingFocusRef.current === null) return;
|
|
212
|
+
const target = containerRef.current?.querySelector(`[data-grid-index="${pendingFocusRef.current}"]`);
|
|
213
|
+
if (target) {
|
|
214
|
+
target.focus();
|
|
215
|
+
pendingFocusRef.current = null;
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
const effectiveFocusedIndex = isControlled ? options.focusedIndex : focusedIndex;
|
|
219
|
+
function handleItemFocus(index) {
|
|
220
|
+
if (!isControlled) setFocusedIndex(index);
|
|
221
|
+
options.onFocusedIndexChange?.(index);
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
containerRef,
|
|
225
|
+
rows,
|
|
226
|
+
cellWidth,
|
|
227
|
+
cellHeight,
|
|
228
|
+
gap: resolvedGap,
|
|
229
|
+
columns: resolvedColumns,
|
|
230
|
+
onLoad,
|
|
231
|
+
onError,
|
|
232
|
+
virtualWindow,
|
|
233
|
+
focusedIndex: effectiveFocusedIndex,
|
|
234
|
+
handleItemFocus,
|
|
235
|
+
handleItemKeyDown
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// src/GridGallery.tsx
|
|
240
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
241
|
+
function GridGallery({ items, renderItem, scrollContainerRef, ...options }) {
|
|
242
|
+
const { containerRef, rows, cellHeight, gap, columns, onLoad, onError, virtualWindow, focusedIndex, handleItemFocus, handleItemKeyDown } = useGridGallery(
|
|
243
|
+
items,
|
|
244
|
+
options,
|
|
245
|
+
scrollContainerRef
|
|
246
|
+
);
|
|
247
|
+
const firstIndex = virtualWindow?.firstIndex ?? 0;
|
|
248
|
+
const lastIndex = virtualWindow?.lastIndex ?? rows.length - 1;
|
|
249
|
+
const visibleRows = virtualWindow ? rows.slice(firstIndex, lastIndex + 1) : rows;
|
|
250
|
+
const padding = options.padding ?? 0;
|
|
251
|
+
const navigable = options.navigable === true;
|
|
252
|
+
return /* @__PURE__ */ jsxs(
|
|
253
|
+
"div",
|
|
254
|
+
{
|
|
255
|
+
ref: containerRef,
|
|
256
|
+
style: { display: "flex", flexDirection: "column", gap: `${gap}px`, padding: padding > 0 ? `${padding}px` : void 0 },
|
|
257
|
+
...navigable ? { role: "grid", "aria-rowcount": rows.length, "aria-colcount": columns } : {},
|
|
258
|
+
children: [
|
|
259
|
+
virtualWindow && virtualWindow.topSpacerHeight > 0 && /* @__PURE__ */ jsx("div", { style: { height: virtualWindow.topSpacerHeight, contain: "layout" } }),
|
|
260
|
+
visibleRows.map((row, i) => {
|
|
261
|
+
const rowIndex = firstIndex + i;
|
|
262
|
+
return /* @__PURE__ */ jsx(
|
|
263
|
+
"div",
|
|
264
|
+
{
|
|
265
|
+
style: { display: "grid", gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: `${gap}px`, contain: "layout" },
|
|
266
|
+
...navigable ? { role: "row", "aria-rowindex": rowIndex + 1 } : {},
|
|
267
|
+
children: row.items.map(({ item, loaded }, colIdx) => {
|
|
268
|
+
const itemIndex = rowIndex * columns + colIdx;
|
|
269
|
+
const focused = navigable && focusedIndex === itemIndex;
|
|
270
|
+
return /* @__PURE__ */ jsx(
|
|
271
|
+
"div",
|
|
272
|
+
{
|
|
273
|
+
style: { height: `${cellHeight}px` },
|
|
274
|
+
...navigable ? {
|
|
275
|
+
role: "gridcell",
|
|
276
|
+
"aria-colindex": colIdx + 1,
|
|
277
|
+
tabIndex: focused ? 0 : -1,
|
|
278
|
+
"data-grid-index": itemIndex,
|
|
279
|
+
onKeyDown: (e) => handleItemKeyDown(itemIndex, e),
|
|
280
|
+
onFocus: () => handleItemFocus(itemIndex)
|
|
281
|
+
} : {},
|
|
282
|
+
children: renderItem(
|
|
283
|
+
item,
|
|
284
|
+
{ loaded, focused },
|
|
285
|
+
{
|
|
286
|
+
onLoad: () => onLoad(item.key),
|
|
287
|
+
onError: () => onError(item.key)
|
|
288
|
+
}
|
|
289
|
+
)
|
|
290
|
+
},
|
|
291
|
+
item.key
|
|
292
|
+
);
|
|
293
|
+
})
|
|
294
|
+
},
|
|
295
|
+
rowIndex
|
|
296
|
+
);
|
|
297
|
+
}),
|
|
298
|
+
virtualWindow && virtualWindow.bottomSpacerHeight > 0 && /* @__PURE__ */ jsx("div", { style: { height: virtualWindow.bottomSpacerHeight, contain: "layout" } })
|
|
299
|
+
]
|
|
300
|
+
}
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
export {
|
|
304
|
+
GridGallery,
|
|
305
|
+
computeGridLayout,
|
|
306
|
+
useGridGallery
|
|
307
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@slithy/react-grid-gallery",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "React grid gallery component.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"import": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"react": "^17 || ^18 || ^19"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@testing-library/jest-dom": "^6",
|
|
21
|
+
"@testing-library/react": "^16",
|
|
22
|
+
"@types/react": "^19",
|
|
23
|
+
"@vitejs/plugin-react": "^6",
|
|
24
|
+
"@vitest/coverage-v8": "^4.1.2",
|
|
25
|
+
"jsdom": "^29.0.1",
|
|
26
|
+
"react": "^19",
|
|
27
|
+
"react-dom": "^19",
|
|
28
|
+
"tsup": "^8",
|
|
29
|
+
"typescript": "^5",
|
|
30
|
+
"vitest": "^4.1.2",
|
|
31
|
+
"@slithy/tsconfig": "0.0.0",
|
|
32
|
+
"@slithy/eslint-config": "0.0.0"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "https://github.com/mjcampagna/react-grid-gallery"
|
|
37
|
+
},
|
|
38
|
+
"author": "mjcampagna",
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"scripts": {
|
|
41
|
+
"sync": "rsync -a --delete ../../../react-grid-gallery/src/ ./src/",
|
|
42
|
+
"clean": "rm -rf dist",
|
|
43
|
+
"build": "rm -rf dist && tsup src/index.ts --format esm --dts",
|
|
44
|
+
"dev": "tsup src/index.ts --format esm --dts --watch",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"lint": "eslint .",
|
|
47
|
+
"test": "vitest run",
|
|
48
|
+
"test:watch": "vitest"
|
|
49
|
+
}
|
|
50
|
+
}
|