@slithy/react-grid-gallery 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -22,8 +22,12 @@ type GridLayoutRow<T> = {
22
22
  height: number;
23
23
  };
24
24
  type GridRow<T> = {
25
+ rowIndex: number;
26
+ startIndex: number;
25
27
  items: Array<{
26
28
  item: GalleryItem<T>;
29
+ itemIndex: number;
30
+ colIndex: number;
27
31
  width: number;
28
32
  height: number;
29
33
  loaded: boolean;
@@ -42,6 +46,7 @@ type VirtualWindow = {
42
46
  declare function useGridGallery<T>(items: GalleryItem<T>[], options: GridOptions, scrollContainerRef?: ScrollContainerRef): {
43
47
  containerRef: RefObject<HTMLDivElement | null>;
44
48
  rows: GridRow<T>[];
49
+ totalRows: number;
45
50
  cellWidth: number;
46
51
  cellHeight: number;
47
52
  gap: number;
package/dist/index.js CHANGED
@@ -1,10 +1,11 @@
1
1
  // src/computeGridLayout.ts
2
2
  function computeGridLayout(items, columns, cellWidth, cellHeight) {
3
- if (items.length === 0 || columns <= 0 || cellWidth <= 0) return [];
3
+ if (items.length === 0 || !Number.isFinite(columns) || !Number.isFinite(cellWidth) || !Number.isFinite(cellHeight) || columns <= 0 || cellWidth <= 0 || cellHeight < 0) return [];
4
+ const columnCount = Math.max(1, Math.floor(columns));
4
5
  const rows = [];
5
- for (let i = 0; i < items.length; i += columns) {
6
+ for (let i = 0; i < items.length; i += columnCount) {
6
7
  rows.push({
7
- items: items.slice(i, i + columns),
8
+ items: items.slice(i, i + columnCount),
8
9
  width: cellWidth,
9
10
  height: cellHeight
10
11
  });
@@ -67,13 +68,19 @@ function useVirtualWindow(containerRef, enabled, scrollContainerRef) {
67
68
  }
68
69
 
69
70
  // src/useGridGallery.ts
71
+ function finitePositive(value, fallback) {
72
+ return Number.isFinite(value) && value > 0 ? value : fallback;
73
+ }
74
+ function finiteNonNegative(value, fallback = 0) {
75
+ return Number.isFinite(value) && value >= 0 ? value : fallback;
76
+ }
70
77
  function useGridGallery(items, options, scrollContainerRef) {
71
78
  const containerRef = useRef2(null);
72
79
  const [containerWidth, setContainerWidth] = useState2(0);
73
80
  const [focusedIndex, setFocusedIndex] = useState2(0);
74
81
  const pendingFocusRef = useRef2(null);
75
82
  const loadedSet = useRef2(/* @__PURE__ */ new Set());
76
- const [loadedVersion, setLoadedVersion] = useState2(0);
83
+ const [loadedTick, setLoadedTick] = useState2(0);
77
84
  const virtualRange = useVirtualWindow(containerRef, options.virtualize === true, scrollContainerRef);
78
85
  useEffect2(() => {
79
86
  const observer = new ResizeObserver((entries) => {
@@ -87,49 +94,83 @@ function useGridGallery(items, options, scrollContainerRef) {
87
94
  const onLoad = useCallback((key) => {
88
95
  if (!loadedSet.current.has(key)) {
89
96
  loadedSet.current.add(key);
90
- setLoadedVersion((v) => v + 1);
97
+ setLoadedTick((v) => v + 1);
91
98
  }
92
99
  }, []);
93
100
  const onError = useCallback((_key) => {
94
101
  }, []);
102
+ const rawColumns = typeof options.columns === "function" ? options.columns(containerWidth) : options.columns;
95
103
  const resolvedColumns = Math.max(
96
104
  1,
97
- Math.round(typeof options.columns === "function" ? options.columns(containerWidth) : options.columns)
105
+ Math.round(finitePositive(rawColumns, 1))
98
106
  );
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;
107
+ const rawGap = typeof options.gap === "function" ? options.gap(containerWidth) : options.gap ?? 0;
108
+ const resolvedGap = finiteNonNegative(rawGap);
109
+ const rawAspectRatio = typeof options.aspectRatio === "function" ? options.aspectRatio(containerWidth) : options.aspectRatio ?? 1;
110
+ const resolvedAspectRatio = finitePositive(rawAspectRatio, 1);
111
+ const cellWidth = containerWidth > 0 ? Math.max(0, Math.floor((containerWidth - resolvedGap * (resolvedColumns - 1)) / resolvedColumns)) : 0;
102
112
  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]);
113
+ const hasLayout = cellWidth > 0 && cellHeight >= 0 && items.length > 0;
114
+ const totalRows = hasLayout ? Math.ceil(items.length / resolvedColumns) : 0;
116
115
  const rowStride = cellHeight + resolvedGap;
117
116
  let virtualWindow = null;
118
- if (options.virtualize && virtualRange !== null && rows.length > 0) {
119
- const totalRows = rows.length;
117
+ if (options.virtualize && virtualRange !== null && totalRows > 0 && rowStride > 0) {
120
118
  const overscan = options.overscan ?? cellHeight * 4;
121
119
  const visibleTop = virtualRange.top - overscan;
122
120
  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);
121
+ const maxRowIndex = totalRows - 1;
122
+ const firstIndex = Math.min(maxRowIndex, Math.max(0, Math.floor(visibleTop / rowStride)));
123
+ let lastIndex = Math.min(maxRowIndex, Math.max(0, Math.ceil(visibleBottom / rowStride) - 1));
125
124
  if (firstIndex > lastIndex) {
126
- firstIndex = 0;
127
- lastIndex = totalRows - 1;
125
+ lastIndex = firstIndex;
128
126
  }
129
127
  const topSpacerHeight = firstIndex * rowStride;
130
128
  const bottomSpacerHeight = (totalRows - 1 - lastIndex) * rowStride;
131
129
  virtualWindow = { firstIndex, lastIndex, topSpacerHeight, bottomSpacerHeight };
132
130
  }
131
+ const rows = useMemo(() => {
132
+ if (!hasLayout) return [];
133
+ if (!options.virtualize) {
134
+ const layoutRows = computeGridLayout(items, resolvedColumns, cellWidth, cellHeight);
135
+ return layoutRows.map((row, rowIndex) => {
136
+ const startIndex = rowIndex * resolvedColumns;
137
+ return {
138
+ rowIndex,
139
+ startIndex,
140
+ height: row.height,
141
+ items: row.items.map((item, colIndex) => ({
142
+ item,
143
+ itemIndex: startIndex + colIndex,
144
+ colIndex,
145
+ width: row.width,
146
+ height: row.height,
147
+ loaded: loadedSet.current.has(item.key)
148
+ }))
149
+ };
150
+ });
151
+ }
152
+ if (virtualWindow === null) return [];
153
+ const renderRows = [];
154
+ for (let rowIndex = virtualWindow.firstIndex; rowIndex <= virtualWindow.lastIndex; rowIndex++) {
155
+ const startIndex = rowIndex * resolvedColumns;
156
+ const endIndex = Math.min(startIndex + resolvedColumns, items.length);
157
+ const rowItems = items.slice(startIndex, endIndex);
158
+ renderRows.push({
159
+ rowIndex,
160
+ startIndex,
161
+ height: cellHeight,
162
+ items: rowItems.map((item, colIndex) => ({
163
+ item,
164
+ itemIndex: startIndex + colIndex,
165
+ colIndex,
166
+ width: cellWidth,
167
+ height: cellHeight,
168
+ loaded: loadedSet.current.has(item.key)
169
+ }))
170
+ });
171
+ }
172
+ return renderRows;
173
+ }, [cellHeight, cellWidth, hasLayout, items, options.virtualize, resolvedColumns, virtualWindow, loadedTick]);
133
174
  const isControlled = options.focusedIndex !== void 0;
134
175
  const padding = options.padding ?? 0;
135
176
  function scrollToRow(rowIndex) {
@@ -223,6 +264,7 @@ function useGridGallery(items, options, scrollContainerRef) {
223
264
  return {
224
265
  containerRef,
225
266
  rows,
267
+ totalRows,
226
268
  cellWidth,
227
269
  cellHeight,
228
270
  gap: resolvedGap,
@@ -239,14 +281,11 @@ function useGridGallery(items, options, scrollContainerRef) {
239
281
  // src/GridGallery.tsx
240
282
  import { jsx, jsxs } from "react/jsx-runtime";
241
283
  function GridGallery({ items, renderItem, scrollContainerRef, ...options }) {
242
- const { containerRef, rows, cellHeight, gap, columns, onLoad, onError, virtualWindow, focusedIndex, handleItemFocus, handleItemKeyDown } = useGridGallery(
284
+ const { containerRef, rows, totalRows, cellHeight, gap, columns, onLoad, onError, virtualWindow, focusedIndex, handleItemFocus, handleItemKeyDown } = useGridGallery(
243
285
  items,
244
286
  options,
245
287
  scrollContainerRef
246
288
  );
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
289
  const padding = options.padding ?? 0;
251
290
  const navigable = options.navigable === true;
252
291
  return /* @__PURE__ */ jsxs(
@@ -254,18 +293,16 @@ function GridGallery({ items, renderItem, scrollContainerRef, ...options }) {
254
293
  {
255
294
  ref: containerRef,
256
295
  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 } : {},
296
+ ...navigable ? { role: "grid", "aria-rowcount": totalRows, "aria-colcount": columns } : {},
258
297
  children: [
259
298
  virtualWindow && virtualWindow.topSpacerHeight > 0 && /* @__PURE__ */ jsx("div", { style: { height: virtualWindow.topSpacerHeight, contain: "layout" } }),
260
- visibleRows.map((row, i) => {
261
- const rowIndex = firstIndex + i;
299
+ rows.map((row) => {
262
300
  return /* @__PURE__ */ jsx(
263
301
  "div",
264
302
  {
265
303
  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;
304
+ ...navigable ? { role: "row", "aria-rowindex": row.rowIndex + 1 } : {},
305
+ children: row.items.map(({ item, itemIndex, colIndex, loaded }) => {
269
306
  const focused = navigable && focusedIndex === itemIndex;
270
307
  return /* @__PURE__ */ jsx(
271
308
  "div",
@@ -273,7 +310,7 @@ function GridGallery({ items, renderItem, scrollContainerRef, ...options }) {
273
310
  style: { height: `${cellHeight}px` },
274
311
  ...navigable ? {
275
312
  role: "gridcell",
276
- "aria-colindex": colIdx + 1,
313
+ "aria-colindex": colIndex + 1,
277
314
  tabIndex: focused ? 0 : -1,
278
315
  "data-grid-index": itemIndex,
279
316
  onKeyDown: (e) => handleItemKeyDown(itemIndex, e),
@@ -292,7 +329,7 @@ function GridGallery({ items, renderItem, scrollContainerRef, ...options }) {
292
329
  );
293
330
  })
294
331
  },
295
- rowIndex
332
+ row.rowIndex
296
333
  );
297
334
  }),
298
335
  virtualWindow && virtualWindow.bottomSpacerHeight > 0 && /* @__PURE__ */ jsx("div", { style: { height: virtualWindow.bottomSpacerHeight, contain: "layout" } })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slithy/react-grid-gallery",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "React grid gallery component.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -28,13 +28,16 @@
28
28
  "tsup": "^8",
29
29
  "typescript": "^5",
30
30
  "vitest": "^4.1.2",
31
- "@slithy/tsconfig": "0.0.0",
32
- "@slithy/eslint-config": "0.0.0"
31
+ "@slithy/eslint-config": "0.0.0",
32
+ "@slithy/tsconfig": "0.0.0"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",
36
36
  "url": "https://github.com/mjcampagna/react-grid-gallery"
37
37
  },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
38
41
  "author": "mjcampagna",
39
42
  "license": "MIT",
40
43
  "scripts": {