@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 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`).
@@ -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
+ }