@mohasinac/appkit 4.0.0 → 4.0.2

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.
@@ -29,7 +29,7 @@ export interface HorizontalScrollerProps<T = unknown> {
29
29
  minItemWidth?: number;
30
30
  pauseOnHover?: boolean;
31
31
  itemClassName?: string;
32
- /** Infinite-loop mode: circular slot rendering with scroll teleport no array cloning */
32
+ /** When the scroller reaches the last item, snap instantly back to the first instead of stopping. */
33
33
  loop?: boolean;
34
34
  }
35
35
  export declare function HorizontalScroller<T = unknown>({ children, className, gap, snapToItems, showArrows, arrowSize, showScrollbar, showFadeEdges, scrollContainerRef: externalRef, onScroll, items, renderItem, keyExtractor, perView, rows, autoScroll, autoScrollInterval, minItemWidth, pauseOnHover, itemClassName, loop, }: HorizontalScrollerProps<T>): import("react").JSX.Element;
@@ -31,132 +31,60 @@ export function HorizontalScroller({ children, className = "", gap = 16, snapToI
31
31
  const containerRef = (externalRef ??
32
32
  internalRef);
33
33
  const autoScrollTimer = useRef(undefined);
34
- // Prevents re-entrant teleports during the instantaneous scrollLeft reset
35
- const isJumping = useRef(false);
36
- // Prevents re-running the initial scroll-to-real-start on every itemWidth update
37
- const loopInitialized = useRef(false);
34
+ // Tracks the pending "restore smooth scrolling" rAF so a fast reset (autoScroll
35
+ // firing again, or a manual arrow click) can't leave a stale callback that fires
36
+ // after and clobbers a newer scroll-behavior change.
37
+ const instantScrollRaf = useRef(undefined);
38
38
  const normalizedItems = Array.isArray(items) ? items : [];
39
39
  const itemsMode = Array.isArray(items) && renderItem != null;
40
40
  const itemCount = normalizedItems.length;
41
- // Number of clone slots on each side — fixed size, computed once from perView hint.
42
- // Using Math.min so tiny lists don't double-render more slots than they have items.
43
- const loopCloneCount = loop && itemsMode && rows <= 1 && itemCount > 0
44
- ? Math.min(itemCount, typeof perView === "number" ? perView : 3)
45
- : 0;
46
- // Grid mode (rows > 1): items are grouped into full-width "slides". Looping here
47
- // clones exactly one slide on each side (the slide itself is the full stride, so a
48
- // single buffer slide is enough) and teleports across a full cycle at the boundary —
49
- // same technique as the single-item loop above, just at slide granularity.
50
41
  const gridMode = itemsMode && rows > 1;
51
42
  const gridCols = colCount > 0 ? colCount : 3;
52
43
  const gridCardsPerSlide = rows * gridCols;
53
44
  const gridSlideCount = gridMode
54
45
  ? Math.ceil(itemCount / gridCardsPerSlide)
55
46
  : 0;
56
- const gridLoopActive = loop && gridMode && gridSlideCount > 1;
57
- // On first paint after itemWidth resolves, scroll to the first *real* item
58
- // (skipping the left clone slots that provide backward-wrap buffer).
59
- useEffect(() => {
60
- if (!loop || loopCloneCount === 0 || itemWidth === undefined)
61
- return;
62
- const el = containerRef.current;
63
- if (!el || loopInitialized.current)
64
- return;
65
- loopInitialized.current = true;
66
- el.style.scrollBehavior = "auto";
67
- el.scrollLeft = loopCloneCount * (itemWidth + gap);
68
- requestAnimationFrame(() => {
69
- el.style.scrollBehavior = "";
70
- });
71
- }, [loop, loopCloneCount, itemWidth, gap, containerRef]);
72
- // Circular teleporter: when scroll enters a clone zone, jump by exactly one
73
- // full cycle so the viewport content is unchanged but we're back in real territory.
74
- const handleScrollLoop = useCallback(() => {
75
- if (!loop || loopCloneCount === 0 || itemWidth === undefined)
76
- return;
77
- const el = containerRef.current;
78
- if (!el || isJumping.current)
79
- return;
80
- const stride = itemWidth + gap;
81
- const cycleWidth = itemCount * stride;
82
- // scrollLeft where real items start and end
83
- const realStart = loopCloneCount * stride;
84
- const realEnd = realStart + cycleWidth;
85
- if (el.scrollLeft < realStart - stride * 0.5) {
86
- // Entered left clone zone — jump forward one cycle
87
- isJumping.current = true;
88
- el.style.scrollBehavior = "auto";
89
- el.scrollLeft += cycleWidth;
90
- requestAnimationFrame(() => {
91
- isJumping.current = false;
92
- el.style.scrollBehavior = "";
93
- });
94
- }
95
- else if (el.scrollLeft > realEnd - el.clientWidth + stride * 0.5) {
96
- // Entered right clone zone — jump backward one cycle
97
- isJumping.current = true;
98
- el.style.scrollBehavior = "auto";
99
- el.scrollLeft -= cycleWidth;
100
- requestAnimationFrame(() => {
101
- isJumping.current = false;
102
- el.style.scrollBehavior = "";
103
- });
47
+ // Instantly jumps to a scroll position used to wrap the loop back to the start
48
+ // (or to the end, for prev-at-start) instead of layering a teleport on top of an
49
+ // in-flight smooth scroll, which is what produced the old flicker/oscillation.
50
+ // Cancels any previously-scheduled "restore smooth scrolling" rAF first so a
51
+ // rapid-fire reset (autoScroll ticking again, or an arrow click) can't leave a
52
+ // stale callback that fires later and clobbers a newer scroll-behavior change.
53
+ const instantScrollTo = useCallback((el, left) => {
54
+ if (instantScrollRaf.current !== undefined) {
55
+ cancelAnimationFrame(instantScrollRaf.current);
104
56
  }
105
- }, [loop, loopCloneCount, itemWidth, gap, itemCount, containerRef]);
106
- // Prevents re-running the initial scroll-to-real-start on every resize in grid loop mode
107
- const gridLoopInitialized = useRef(false);
108
- // On first paint, scroll past the prepended clone slide to the first *real* slide.
109
- useEffect(() => {
110
- if (!gridLoopActive)
111
- return;
112
- const el = containerRef.current;
113
- if (!el || gridLoopInitialized.current)
114
- return;
115
- gridLoopInitialized.current = true;
116
- const stride = el.clientWidth + gap;
117
57
  el.style.scrollBehavior = "auto";
118
- el.scrollLeft = stride;
119
- requestAnimationFrame(() => {
58
+ el.scrollLeft = left;
59
+ instantScrollRaf.current = requestAnimationFrame(() => {
120
60
  el.style.scrollBehavior = "";
61
+ instantScrollRaf.current = undefined;
121
62
  });
122
- }, [gridLoopActive, gap, containerRef]);
123
- // Circular teleporter for grid mode same idea as handleScrollLoop, at slide granularity.
124
- const handleGridScrollLoop = useCallback(() => {
125
- if (!gridLoopActive)
126
- return;
127
- const el = containerRef.current;
128
- if (!el || isJumping.current)
129
- return;
130
- const stride = el.clientWidth + gap;
131
- const cycleWidth = gridSlideCount * stride;
132
- const realStart = stride; // after the 1 prepended clone slide
133
- const realEnd = realStart + cycleWidth;
134
- if (el.scrollLeft < realStart - stride * 0.5) {
135
- isJumping.current = true;
136
- el.style.scrollBehavior = "auto";
137
- el.scrollLeft += cycleWidth;
138
- requestAnimationFrame(() => {
139
- isJumping.current = false;
140
- el.style.scrollBehavior = "";
141
- });
142
- }
143
- else if (el.scrollLeft > realEnd - el.clientWidth + stride * 0.5) {
144
- isJumping.current = true;
145
- el.style.scrollBehavior = "auto";
146
- el.scrollLeft -= cycleWidth;
147
- requestAnimationFrame(() => {
148
- isJumping.current = false;
149
- el.style.scrollBehavior = "";
150
- });
151
- }
152
- }, [gridLoopActive, gap, gridSlideCount, containerRef]);
63
+ }, []);
64
+ // Cancel any pending rAF on unmount so it never fires against a detached node.
65
+ useEffect(() => {
66
+ return () => {
67
+ if (instantScrollRaf.current !== undefined) {
68
+ cancelAnimationFrame(instantScrollRaf.current);
69
+ }
70
+ };
71
+ }, []);
153
72
  const scrollBy = useCallback((direction) => {
154
73
  const el = containerRef.current;
155
74
  if (!el)
156
75
  return;
157
76
  const width = el.clientWidth;
77
+ const maxScroll = el.scrollWidth - width;
78
+ if (loop && direction === 1 && el.scrollLeft >= maxScroll - 1) {
79
+ instantScrollTo(el, 0);
80
+ return;
81
+ }
82
+ if (loop && direction === -1 && el.scrollLeft <= 1) {
83
+ instantScrollTo(el, maxScroll);
84
+ return;
85
+ }
158
86
  el.scrollBy({ left: direction * width * 0.8, behavior: "smooth" });
159
- }, [containerRef]);
87
+ }, [containerRef, loop, instantScrollTo]);
160
88
  const updateExtents = useCallback(() => {
161
89
  const el = containerRef.current;
162
90
  if (!el)
@@ -188,98 +116,57 @@ export function HorizontalScroller({ children, className = "", gap = 16, snapToI
188
116
  const el = containerRef.current;
189
117
  if (!el)
190
118
  return;
191
- // Always advance; handleScrollLoop teleports when clone zone is reached
119
+ const maxScroll = el.scrollWidth - el.clientWidth;
120
+ const atRealEnd = maxScroll <= 1 || el.scrollLeft >= maxScroll - 1;
121
+ if (atRealEnd) {
122
+ if (loop)
123
+ instantScrollTo(el, 0);
124
+ return;
125
+ }
192
126
  el.scrollBy({ left: el.clientWidth * 0.8, behavior: "smooth" });
193
127
  }, autoScrollInterval);
194
128
  return () => clearInterval(autoScrollTimer.current);
195
- }, [autoScroll, isPaused, autoScrollInterval, containerRef]);
129
+ }, [autoScroll, isPaused, autoScrollInterval, loop, instantScrollTo, containerRef]);
196
130
  useEffect(() => {
197
- if (!perView && !loop)
131
+ if (!perView)
198
132
  return;
199
133
  const el = containerRef.current;
200
134
  if (!el)
201
135
  return;
202
136
  const observer = new ResizeObserver(([entry]) => {
203
137
  const w = entry.contentRect.width;
204
- if (perView) {
205
- const count = resolvePerView(perView, w);
206
- if (count > 0) {
207
- setColCount(count);
208
- setItemWidth((w - (count - 1) * gap) / count);
209
- }
210
- }
211
- else {
212
- // loop is true but no perView hint was given: items render at their
213
- // natural width, so measure a real rendered item to get the scroll
214
- // stride the clone-buffer offset / edge teleporter need — without
215
- // this, itemWidth never resolves and loop mode never initializes.
216
- const item = el.querySelector(".appkit-hscroller__item");
217
- if (item)
218
- setItemWidth(item.getBoundingClientRect().width);
138
+ const count = resolvePerView(perView, w);
139
+ if (count > 0) {
140
+ setColCount(count);
141
+ setItemWidth((w - (count - 1) * gap) / count);
219
142
  }
220
143
  updateExtents();
221
144
  });
222
145
  observer.observe(el);
223
146
  return () => observer.disconnect();
224
- }, [perView, loop, gap, containerRef, updateExtents]);
147
+ }, [perView, gap, containerRef, updateExtents]);
225
148
  // Recompute extents when content size changes (itemWidth resolved, items count changes).
226
149
  useEffect(() => {
227
150
  updateExtents();
228
151
  }, [updateExtents, itemWidth, itemCount]);
229
152
  const content = itemsMode ? (rows > 1 ? (
230
153
  // Grid mode: group items into slides of (rows × colCount) cards.
231
- // Loop mode prepends a clone of the last slide and appends a clone of the
232
- // first slide; handleGridScrollLoop teleports across a full cycle at the
233
- // boundary so the strip appears to scroll infinitely.
234
- (() => {
235
- const renderSlide = (slideIndex, key, isClone) => {
236
- const slideItems = normalizedItems.slice(slideIndex * gridCardsPerSlide, (slideIndex + 1) * gridCardsPerSlide);
237
- return (_jsx("div", { className: "appkit-hscroller__slide", style: {
238
- display: "grid",
239
- gridTemplateColumns: `repeat(${gridCols}, 1fr)`,
240
- gap: `${gap}px`,
241
- width: "100%",
242
- flexShrink: 0,
243
- }, "aria-hidden": isClone ? true : undefined, children: slideItems.map((item, idx) => (_jsx("div", { className: [
244
- "appkit-hscroller__item",
245
- snapToItems ? "appkit-hscroller__item--snap" : "",
246
- itemClassName,
247
- ]
248
- .filter(Boolean)
249
- .join(" "), style: minItemWidth ? { minWidth: minItemWidth } : undefined, children: renderItem(item, slideIndex * gridCardsPerSlide + idx) }, keyExtractor ? keyExtractor(item, slideIndex * gridCardsPerSlide + idx) : slideIndex * gridCardsPerSlide + idx))) }, key));
250
- };
251
- const realSlides = Array.from({ length: gridSlideCount }, (_, slideIndex) => renderSlide(slideIndex, `slide-${slideIndex}`, false));
252
- if (!gridLoopActive)
253
- return realSlides;
254
- return [
255
- renderSlide(gridSlideCount - 1, "slide-clone-start", true),
256
- ...realSlides,
257
- renderSlide(0, "slide-clone-end", true),
258
- ];
259
- })()) : loop && itemCount > 0 ? (
260
- // Circular loop: fixed (itemCount + 2×loopCloneCount) DOM slots.
261
- // Each slot maps to a real item via modulo — no array cloning, no list growth.
262
- // Left slots [0 .. loopCloneCount-1] → tail of real list (left buffer)
263
- // Real slots [loopCloneCount .. loopCloneCount+n-1] → items[0..n-1]
264
- // Right slots [loopCloneCount+n .. end] → head of real list (right buffer)
265
- Array.from({ length: itemCount + 2 * loopCloneCount }, (_, i) => {
266
- const realIndex = ((i - loopCloneCount) % itemCount + itemCount) % itemCount;
267
- const item = normalizedItems[realIndex];
268
- const isClone = i < loopCloneCount || i >= loopCloneCount + itemCount;
269
- return (_jsx("div", { className: [
270
- "appkit-hscroller__item",
271
- snapToItems ? "appkit-hscroller__item--snap" : "",
272
- itemClassName,
273
- ]
274
- .filter(Boolean)
275
- .join(" "), style: itemWidth !== undefined
276
- ? { width: itemWidth, flexShrink: 0 }
277
- : minItemWidth
278
- ? { minWidth: minItemWidth }
279
- : undefined, "aria-hidden": isClone ? true : undefined, children: renderItem(item, realIndex) }, `loop-slot-${i}`));
280
- })) : (
281
- // Normal single row (no loop)
282
- normalizedItems.map((item, i) => (_jsx("div", { className: [
154
+ Array.from({ length: gridSlideCount }, (_, slideIndex) => {
155
+ const slideItems = normalizedItems.slice(slideIndex * gridCardsPerSlide, (slideIndex + 1) * gridCardsPerSlide);
156
+ return (_jsx("div", { className: "appkit-hscroller__slide", style: {
157
+ display: "grid",
158
+ gridTemplateColumns: `repeat(${gridCols}, 1fr)`,
159
+ gap: `${gap}px`,
160
+ width: "100%",
161
+ flexShrink: 0,
162
+ }, children: slideItems.map((item, idx) => (_jsx("div", { className: [
163
+ "appkit-hscroller__item",
164
+ snapToItems ? "appkit-hscroller__item--snap" : "",
165
+ itemClassName,
166
+ ]
167
+ .filter(Boolean)
168
+ .join(" "), style: minItemWidth ? { minWidth: minItemWidth } : undefined, children: renderItem(item, slideIndex * gridCardsPerSlide + idx) }, keyExtractor ? keyExtractor(item, slideIndex * gridCardsPerSlide + idx) : slideIndex * gridCardsPerSlide + idx))) }, `slide-${slideIndex}`));
169
+ })) : (normalizedItems.map((item, i) => (_jsx("div", { className: [
283
170
  "appkit-hscroller__item",
284
171
  snapToItems ? "appkit-hscroller__item--snap" : "",
285
172
  itemClassName,
@@ -290,40 +177,34 @@ export function HorizontalScroller({ children, className = "", gap = 16, snapToI
290
177
  : minItemWidth
291
178
  ? { minWidth: minItemWidth }
292
179
  : undefined, children: renderItem(item, i) }, keyExtractor ? keyExtractor(item, i) : i))))) : (children);
293
- const hoverHandlers = pauseOnHover
180
+ // Pauses autoScroll for the duration of any user interaction — hover, touch,
181
+ // keyboard focus (arrow-key nav), or an active wheel/drag scroll — so autoplay
182
+ // never fights a scroll the user is in the middle of driving themselves.
183
+ const interactionHandlers = pauseOnHover
294
184
  ? {
295
185
  onMouseEnter: () => setIsPaused(true),
296
186
  onMouseLeave: () => setIsPaused(false),
297
187
  onTouchStart: () => setIsPaused(true),
298
188
  onTouchEnd: () => setIsPaused(false),
299
189
  onTouchCancel: () => setIsPaused(false),
190
+ onFocus: () => setIsPaused(true),
191
+ onBlur: () => setIsPaused(false),
192
+ onWheel: () => setIsPaused(true),
300
193
  }
301
194
  : {};
302
195
  const combinedOnScroll = () => {
303
- if (gridMode) {
304
- if (gridLoopActive)
305
- handleGridScrollLoop();
306
- else
307
- updateExtents();
308
- }
309
- else {
310
- if (loop)
311
- handleScrollLoop();
312
- if (!loop)
313
- updateExtents();
314
- }
196
+ updateExtents();
315
197
  onScroll?.();
316
198
  };
317
199
  if (showArrows) {
318
- const effectiveLoop = gridMode ? gridLoopActive : loop;
319
- const prevDisabled = !effectiveLoop && atStart;
320
- const nextDisabled = !effectiveLoop && atEnd;
321
- const arrowsHidden = !effectiveLoop && atStart && atEnd; // no scrollable overflow
200
+ const prevDisabled = !loop && atStart;
201
+ const nextDisabled = !loop && atEnd;
202
+ const arrowsHidden = !loop && atStart && atEnd; // no scrollable overflow
322
203
  return (_jsxs("div", { className: ["appkit-hscroller appkit-hscroller--with-arrows", className]
323
204
  .filter(Boolean)
324
- .join(" "), tabIndex: 0, onKeyDown: handleKeyDown, ...hoverHandlers, "data-section": "horizontalscroller-div-511", children: [showFadeEdges && (_jsxs(_Fragment, { children: [_jsx("div", { className: "appkit-hscroller__fade appkit-hscroller__fade--left" }), _jsx("div", { className: "appkit-hscroller__fade appkit-hscroller__fade--right" })] })), !arrowsHidden && (_jsx("button", { type: "button", onClick: () => scrollBy(-1), "aria-label": "Previous", "aria-disabled": prevDisabled || undefined, disabled: prevDisabled, className: `appkit-hscroller__arrow appkit-hscroller__arrow--prev appkit-hscroller__arrow--${arrowSize}`, children: _jsx(ChevronLeft, { className: "w-4 h-4" }) })), _jsx("div", { ref: containerRef, onScroll: combinedOnScroll, className: scrollerCls(snapToItems, showScrollbar), style: { gap: `${gap}px`, paddingLeft: 36, paddingRight: 36 }, "data-section": "horizontalscroller-div-512", children: content }), !arrowsHidden && (_jsx("button", { type: "button", onClick: () => scrollBy(1), "aria-label": "Next", "aria-disabled": nextDisabled || undefined, disabled: nextDisabled, className: `appkit-hscroller__arrow appkit-hscroller__arrow--next appkit-hscroller__arrow--${arrowSize}`, children: _jsx(ChevronRight, { className: "w-4 h-4" }) }))] }));
205
+ .join(" "), tabIndex: 0, onKeyDown: handleKeyDown, ...interactionHandlers, "data-section": "horizontalscroller-div-511", children: [showFadeEdges && (_jsxs(_Fragment, { children: [_jsx("div", { className: "appkit-hscroller__fade appkit-hscroller__fade--left" }), _jsx("div", { className: "appkit-hscroller__fade appkit-hscroller__fade--right" })] })), !arrowsHidden && (_jsx("button", { type: "button", onClick: () => scrollBy(-1), "aria-label": "Previous", "aria-disabled": prevDisabled || undefined, disabled: prevDisabled, className: `appkit-hscroller__arrow appkit-hscroller__arrow--prev appkit-hscroller__arrow--${arrowSize}`, children: _jsx(ChevronLeft, { className: "w-4 h-4" }) })), _jsx("div", { ref: containerRef, onScroll: combinedOnScroll, className: scrollerCls(snapToItems, showScrollbar), style: { gap: `${gap}px`, paddingLeft: 36, paddingRight: 36 }, "data-section": "horizontalscroller-div-512", children: content }), !arrowsHidden && (_jsx("button", { type: "button", onClick: () => scrollBy(1), "aria-label": "Next", "aria-disabled": nextDisabled || undefined, disabled: nextDisabled, className: `appkit-hscroller__arrow appkit-hscroller__arrow--next appkit-hscroller__arrow--${arrowSize}`, children: _jsx(ChevronRight, { className: "w-4 h-4" }) }))] }));
325
206
  }
326
- return (_jsxs("div", { className: ["appkit-hscroller", className].filter(Boolean).join(" "), tabIndex: 0, onKeyDown: handleKeyDown, ...hoverHandlers, "data-section": "horizontalscroller-div-513", children: [showFadeEdges && (_jsxs(_Fragment, { children: [_jsx("div", { className: "appkit-hscroller__fade appkit-hscroller__fade--left" }), _jsx("div", { className: "appkit-hscroller__fade appkit-hscroller__fade--right" })] })), _jsx("div", { ref: containerRef, onScroll: combinedOnScroll, className: scrollerCls(snapToItems, showScrollbar), style: { gap: `${gap}px` }, "data-section": "horizontalscroller-div-514", children: content })] }));
207
+ return (_jsxs("div", { className: ["appkit-hscroller", className].filter(Boolean).join(" "), tabIndex: 0, onKeyDown: handleKeyDown, ...interactionHandlers, "data-section": "horizontalscroller-div-513", children: [showFadeEdges && (_jsxs(_Fragment, { children: [_jsx("div", { className: "appkit-hscroller__fade appkit-hscroller__fade--left" }), _jsx("div", { className: "appkit-hscroller__fade appkit-hscroller__fade--right" })] })), _jsx("div", { ref: containerRef, onScroll: combinedOnScroll, className: scrollerCls(snapToItems, showScrollbar), style: { gap: `${gap}px` }, "data-section": "horizontalscroller-div-514", children: content })] }));
327
208
  }
328
209
  function scrollerCls(snapToItems, showScrollbar) {
329
210
  return [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.0.0",
3
+ "version": "4.0.2",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"