@ceriousdevtech/react-cerious-scroll 1.0.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/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog
2
+
3
+ All notable changes to react-cerious-scroll will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.0.1] - 2026-06-01
9
+
10
+ ### Changed
11
+ - Verified compatibility with `@ceriousdevtech/cerious-scroll` 1.0.2
12
+
13
+ ### Dependencies
14
+ - Peer dependency `@ceriousdevtech/cerious-scroll` tested against `^1.0.2` (range `^1.0.1` already satisfies this)
15
+
16
+ ---
17
+
18
+ ## [1.0.0] - 2026-02-02
19
+
20
+ ### Added
21
+ - Initial release of `@ceriousdevtech/react-cerious-scroll`
22
+ - `<CeriousScroll>` component — drop-in virtual scroll list for React 18+
23
+ - `useCeriousScroll` hook for headless usage with custom container elements
24
+ - React portal rendering for each row — rows live in the React tree so Context, providers, and refs work normally
25
+ - Synchronous height measurement (no estimated heights, no correction passes)
26
+ - Full TypeScript support with exported prop and hook types
27
+ - Peer dependency on `@ceriousdevtech/cerious-scroll` core engine
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024–2026 Cerious DevTech LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,158 @@
1
+ # @ceriousdevtech/react-cerious-scroll
2
+
3
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
4
+ [![Live Demo](https://img.shields.io/badge/demo-live-brightgreen)](https://ceriousdevtech.github.io/react-cerious-scroll/)
5
+
6
+ **React bindings for [Cerious Scroll™](https://www.npmjs.com/package/@ceriousdevtech/cerious-scroll)** — high-performance virtual scrolling with **O(1) memory**, consistent **60 FPS+**, and **native variable-height support with no height estimation**.
7
+
8
+ Rows are rendered into the engine's own measured containers via React portals and committed synchronously, so every row's real height is measured (never estimated) — exactly the guarantee that makes CeriousScroll precise. Because rows stay in your React tree, **Context / providers work normally**.
9
+
10
+ ---
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ npm install @ceriousdevtech/react-cerious-scroll @ceriousdevtech/cerious-scroll
16
+ ```
17
+
18
+ `react` and `react-dom` (>= 18) are peer dependencies.
19
+
20
+ ---
21
+
22
+ ## Demo
23
+
24
+ **[Live demo →](https://ceriousdevtech.github.io/react-cerious-scroll/)** — 100,000 rows, fixed/variable-height toggle, imperative jump-to-row, and live viewport stats.
25
+
26
+ To run locally:
27
+
28
+ ```bash
29
+ npm install
30
+ npm run demo # dev server with HMR
31
+ npm run demo:build # production build to demo/dist
32
+ ```
33
+
34
+ The demo imports the wrapper by its package name, aliased to the library source,
35
+ so edits to `src/` are reflected live.
36
+
37
+ ---
38
+
39
+ ## Quick start (component)
40
+
41
+ Give the container a height; provide `items` and a `renderItem` render prop.
42
+
43
+ ```tsx
44
+ import { CeriousScroll } from '@ceriousdevtech/react-cerious-scroll';
45
+
46
+ const items = Array.from({ length: 1_000_000 }, (_, i) => ({ id: i, name: `Item ${i}` }));
47
+
48
+ export function List() {
49
+ return (
50
+ <CeriousScroll
51
+ items={items}
52
+ renderItem={(item, index) => (
53
+ <div className="row">
54
+ {index} — {item.name}
55
+ </div>
56
+ )}
57
+ style={{ height: 480 }}
58
+ />
59
+ );
60
+ }
61
+ ```
62
+
63
+ Variable heights need no configuration — just render rows of whatever height; the
64
+ engine measures each one.
65
+
66
+ ### Without a full array (huge / sparse data)
67
+
68
+ ```tsx
69
+ <CeriousScroll
70
+ totalElements={100_000_000}
71
+ getItem={(index) => loadRow(index)}
72
+ renderItem={(row, index) => <Row data={row} index={index} />}
73
+ style={{ height: 600 }}
74
+ />
75
+ ```
76
+
77
+ ---
78
+
79
+ ## Hook
80
+
81
+ `useCeriousScroll` gives you full control. Attach `containerRef` to your scroll
82
+ element and render `portals` somewhere in your tree (they attach to their own DOM
83
+ targets, so placement only affects which React Context they inherit).
84
+
85
+ ```tsx
86
+ import { useCeriousScroll } from '@ceriousdevtech/react-cerious-scroll';
87
+
88
+ function List() {
89
+ const { containerRef, portals } = useCeriousScroll({
90
+ items,
91
+ renderItem: (item, index) => <Row item={item} index={index} />,
92
+ });
93
+
94
+ return (
95
+ <div ref={containerRef} style={{ height: 480, position: 'relative', overflow: 'hidden' }}>
96
+ {portals}
97
+ </div>
98
+ );
99
+ }
100
+ ```
101
+
102
+ ---
103
+
104
+ ## Component props
105
+
106
+ | Prop | Type | Description |
107
+ | --- | --- | --- |
108
+ | `renderItem` | `(item, index) => ReactNode` | **Required.** Renders one row. `item` is `undefined` if no data source is given. |
109
+ | `items` | `readonly TItem[]` | Optional data array. `totalElements` defaults to `items.length`. |
110
+ | `totalElements` | `number` | Total item count. Required if `items` is omitted. |
111
+ | `getItem` | `(index) => TItem` | Lazy item getter for large/sparse datasets. |
112
+ | `options` | `CeriousScrollOptions` | Engine options (keyboard/touch/wheel/scrollbar/etc.). Read once at creation. |
113
+ | `autoRender` | `boolean` | Re-render on scroll/resize/data changes. Default `true`. |
114
+ | `onViewportChange` | `(detail) => void` | Normalized viewport-change callback. |
115
+ | `onMeasuredViewport` | `(range) => void` | Measured range after each render pass. |
116
+ | `onReady` | `(scroller) => void` | The underlying engine instance, once ready. |
117
+ | `className` / `style` | — | Applied to the scroll container (set a height!). |
118
+
119
+ ### Imperative handle (via `ref`)
120
+
121
+ ```tsx
122
+ const ref = useRef<CeriousScrollHandle>(null);
123
+ // ref.current?.jumpToElement(500);
124
+ // ref.current?.scrollToPercentage(50);
125
+ // ref.current?.reset();
126
+ // ref.current?.render();
127
+ // ref.current?.recalculate(); // drop cached heights + re-measure (see Notes)
128
+ // ref.current?.scroller; // the raw engine
129
+ ```
130
+
131
+ ---
132
+
133
+ ## Notes
134
+
135
+ - **No height estimation.** Rows are committed with `flushSync` so the engine
136
+ measures real `offsetHeight`. Later size changes are picked up by the engine's
137
+ built-in `ResizeObserver`.
138
+ - **`options` are read at creation.** Changing `options` after mount has no
139
+ effect; remount (e.g. with a `key`) to apply new engine options.
140
+ - **Changing the item count** recreates the engine internally (scroll position
141
+ is preserved). Mutating items without changing the count just re-renders the
142
+ content (cheap, and your row state is preserved) — it does **not** discard
143
+ cached heights, so editable grids that produce a new `items` array on every
144
+ edit don't trigger a full viewport re-measure.
145
+ - **If every rendered row's height changes at once** (e.g. a density/layout
146
+ switch) the cached heights become stale and rows can misalign until the next
147
+ scroll. Call `recalculate()` (on the `ref`, or from the hook result) right
148
+ after the change to drop the height cache and re-measure. Don't call it on
149
+ routine edits — a single cell edit keeps its row's size, and the engine's
150
+ built-in `ResizeObserver` picks up any incidental resize on its own.
151
+
152
+ ---
153
+
154
+ ## License
155
+
156
+ Licensed by **Cerious DevTech LLC** under the **MIT License** (see `LICENSE-MIT`).
157
+
158
+ 📧 info@ceriousdevtech.com
@@ -0,0 +1,35 @@
1
+ import { CSSProperties, ReactElement, Ref } from 'react';
2
+ import { CeriousScroll as CeriousScrollEngine, MeasuredViewportRange } from '@ceriousdevtech/cerious-scroll';
3
+ import { UseCeriousScrollOptions } from './use-cerious-scroll';
4
+ export interface CeriousScrollProps<TItem = unknown> extends UseCeriousScrollOptions<TItem> {
5
+ /** Class applied to the scroll container. */
6
+ className?: string;
7
+ /**
8
+ * Inline styles for the scroll container. You must give the container a
9
+ * height (e.g. `style={{ height: 400 }}`) for scrolling to work.
10
+ */
11
+ style?: CSSProperties;
12
+ /** Forwarded to the scroll container for testing. */
13
+ 'data-testid'?: string;
14
+ }
15
+ export interface CeriousScrollHandle {
16
+ /** The underlying engine instance (`null` before mount). */
17
+ scroller: CeriousScrollEngine | null;
18
+ render(): MeasuredViewportRange | null;
19
+ jumpToElement(index: number): MeasuredViewportRange | null;
20
+ scrollToPercentage(percentage: number): MeasuredViewportRange | null;
21
+ reset(): MeasuredViewportRange | null;
22
+ /**
23
+ * Discard all cached row heights and re-measure. Use only when the heights of
24
+ * already-rendered rows changed without their indices changing (e.g. a global
25
+ * density/layout switch) — not for routine cell edits.
26
+ */
27
+ recalculate(): MeasuredViewportRange | null;
28
+ }
29
+ /**
30
+ * High-performance virtual scroll list. Provide `items` (or `totalElements` +
31
+ * `getItem`) and a `renderItem` render prop; give the container a height.
32
+ */
33
+ export declare const CeriousScroll: <TItem = unknown>(props: CeriousScrollProps<TItem> & {
34
+ ref?: Ref<CeriousScrollHandle>;
35
+ }) => ReactElement;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * DOM helpers shared by the React bindings.
3
+ *
4
+ * Copyright (c) 2024-2026 Cerious DevTech LLC. All rights reserved.
5
+ */
6
+ /** Attribute marking the dedicated content element rows are rendered into. */
7
+ export declare const CONTENT_ATTR = "data-cerious-scroll-content";
8
+ /** Attribute marking the inner mount node React renders each row into. */
9
+ export declare const ROW_ATTR = "data-cerious-scroll-row";
10
+ /**
11
+ * Ensure a dedicated, recyclable content element exists inside `container`.
12
+ *
13
+ * CeriousScroll renders/recycles row containers (clearing them with
14
+ * `textContent`/`innerHTML`) inside this element, while the native scrollbar
15
+ * and event listeners stay attached to the outer `container`. Keeping them
16
+ * separate prevents the scrollbar DOM from being wiped during rendering.
17
+ */
18
+ export declare function ensureContentElement(container: HTMLElement): HTMLElement;
package/dist/index.cjs ADDED
@@ -0,0 +1 @@
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const J=require("react/jsx-runtime"),r=require("react"),H=require("react-dom"),K=require("react-dom/server"),_=require("@ceriousdevtech/cerious-scroll"),V="data-cerious-scroll-content",Q="data-cerious-scroll-row";function U(e){const l=e.querySelector(`[${V}]`);if(l)return l;const n=document.createElement("div");return n.setAttribute(V,""),n.style.position="relative",n.style.width="100%",n.style.height="100%",n.style.overflow="hidden",e.appendChild(n),n}function X(e,l){const n=u=>{const o=u.detail;o&&l({percentage:o.percentage,currentElement:o.currentElement,scrollOffset:o.scrollOffset,result:{element:o.result.element,offset:o.result.offset}})},a=u=>{const o=u.detail;o&&l({percentage:o.percentage,currentElement:o.element,scrollOffset:o.scrollOffset,result:{element:o.element,offset:o.scrollOffset}})};return e.addEventListener("cerious-viewport-change",n),e.addEventListener("viewport-change",a),()=>{e.removeEventListener("cerious-viewport-change",n),e.removeEventListener("viewport-change",a)}}function Y(e,l){const n=typeof e=="number"?e:typeof l=="number"?l:void 0;if(n===void 0||Number.isNaN(n))throw new Error("useCeriousScroll: provide `totalElements` or `items`.");return Math.max(1,Math.floor(n))}function D(e){var N;const l=r.useRef(null),n=r.useRef(null),a=r.useRef(new Map),u=r.useRef(null),[,o]=r.useReducer(t=>t+1,0),[q,y]=r.useState(null),E=r.useRef(e.renderItem),d=r.useRef(e.items??null),v=r.useRef(e.getItem),S=r.useRef(e.options),h=r.useRef(e.autoRender??!0),p=r.useRef(e.totalElements??null),O=r.useRef(e.onViewportChange),T=r.useRef(e.onMeasuredViewport),I=r.useRef(e.onReady);E.current=e.renderItem,d.current=e.items??null,v.current=e.getItem,S.current=e.options,h.current=e.autoRender??!0,p.current=e.totalElements??null,O.current=e.onViewportChange,T.current=e.onMeasuredViewport,I.current=e.onReady;const k=r.useCallback(t=>{const c=v.current;if(c)return c(t);const g=d.current;return g?g[t]:void 0},[]),s=r.useCallback(()=>{var w;const t=n.current;if(!t)return null;const{scroller:c,contentEl:g,container:C}=t,j=C.clientHeight||C.offsetHeight||0,b=[],i=(f,R)=>{const m=document.createElement("div");m.setAttribute(Q,String(f)),m.innerHTML=K.renderToStaticMarkup(r.createElement(r.Fragment,null,E.current(k(f),f))),R.appendChild(m),a.current.set(f,{el:R,mount:m}),b.push(m)},A=c.renderViewport(j,g,i),M=new Set(c.getRenderedIndices());return a.current.forEach((f,R)=>{M.has(R)||a.current.delete(R)}),b.forEach(f=>{f.textContent=""}),H.flushSync(o),(w=T.current)==null||w.call(T,A),A},[]),B=e.totalElements??((N=e.items)==null?void 0:N.length)??null;r.useEffect(()=>{var w,f;const t=l.current;if(!t)return;const c=U(t),g=S.current??{},C=g.onScroll,j={...g,onScroll:()=>{C==null||C(),h.current&&s()}},b=Y(p.current,((w=d.current)==null?void 0:w.length)??null),i=new _.CeriousScroll(t,b,j);u.current&&(i.currentElement=Math.min(u.current.currentElement,b-1),i.scrollOffset=u.current.scrollOffset,u.current=null),n.current={scroller:i,contentEl:c,container:t},y(i);const A=X(t,R=>{var m;(m=O.current)==null||m.call(O,R)});(f=I.current)==null||f.call(I,i);let M=0;return h.current&&(M=requestAnimationFrame(()=>s())),()=>{cancelAnimationFrame(M),A(),u.current={currentElement:i.currentElement,scrollOffset:i.scrollOffset},a.current.clear(),c.textContent="",i.detachScrollbar(t),i.dispose(),n.current=null,y(null)}},[B,s]),r.useEffect(()=>{if(!n.current||!h.current)return;const t=requestAnimationFrame(()=>s());return()=>cancelAnimationFrame(t)},[e.items,e.getItem]);const W=r.useCallback(t=>{const c=n.current;return c?(c.scroller.jumpToElement(t),s()):null},[s]),$=r.useCallback(t=>{const c=n.current;return c?(c.scroller.handleScrollPercentage(t),s()):null},[s]),z=r.useCallback(()=>{const t=n.current;return t?(t.scroller.reset(),s()):null},[s]),G=r.useCallback(()=>{const t=n.current;return t?(t.scroller.clearAllCaches(),s()):null},[s]),P=d.current,F=!v.current&&P!=null?P.length:null,L=[];return a.current.forEach((t,c)=>{F!==null&&c>=F||L.push(H.createPortal(E.current(k(c),c),t.mount,String(c)))}),{containerRef:l,portals:L,scroller:q,render:s,jumpToElement:W,scrollToPercentage:$,reset:z,recalculate:G}}function Z(e,l){const{className:n,style:a,"data-testid":u,...o}=e,{containerRef:q,portals:y,scroller:E,render:d,jumpToElement:v,scrollToPercentage:S,reset:h,recalculate:p}=D(o);return r.useImperativeHandle(l,()=>({scroller:E,render:d,jumpToElement:v,scrollToPercentage:S,reset:h,recalculate:p}),[E,d,v,S,h,p]),J.jsx("div",{ref:q,className:n,"data-testid":u,style:{position:"relative",overflow:"hidden",...a},children:y})}const x=r.forwardRef(Z);Object.defineProperty(exports,"CeriousScrollEngine",{enumerable:!0,get:()=>_.CeriousScroll});exports.CeriousScroll=x;exports.useCeriousScroll=D;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @ceriousdevtech/react-cerious-scroll
3
+ *
4
+ * React bindings for the CeriousScroll virtual scrolling engine.
5
+ *
6
+ * Copyright (c) 2024-2026 Cerious DevTech LLC. All rights reserved.
7
+ */
8
+ export { CeriousScroll } from './cerious-scroll';
9
+ export type { CeriousScrollProps, CeriousScrollHandle } from './cerious-scroll';
10
+ export { useCeriousScroll } from './use-cerious-scroll';
11
+ export type { UseCeriousScrollOptions, UseCeriousScrollResult, } from './use-cerious-scroll';
12
+ export type { CeriousViewportChangeDetail } from './viewport-change';
13
+ export { CeriousScroll as CeriousScrollEngine } from '@ceriousdevtech/cerious-scroll';
14
+ export type { CeriousScrollOptions, KeyboardNavigationOptions, TouchNavigationOptions, WheelNavigationOptions, ElementRenderer, ElementHeightCalculator, ScrollResult, MeasuredViewportRange, } from '@ceriousdevtech/cerious-scroll';
package/dist/index.js ADDED
@@ -0,0 +1,160 @@
1
+ import { jsx as G } from "react/jsx-runtime";
2
+ import { useRef as s, useReducer as J, useState as K, useCallback as S, useEffect as _, createElement as Q, Fragment as U, forwardRef as X, useImperativeHandle as Y } from "react";
3
+ import { flushSync as Z, createPortal as x } from "react-dom";
4
+ import { renderToStaticMarkup as ee } from "react-dom/server";
5
+ import { CeriousScroll as te } from "@ceriousdevtech/cerious-scroll";
6
+ import { CeriousScroll as Ee } from "@ceriousdevtech/cerious-scroll";
7
+ const k = "data-cerious-scroll-content", re = "data-cerious-scroll-row";
8
+ function ne(e) {
9
+ const c = e.querySelector(`[${k}]`);
10
+ if (c) return c;
11
+ const r = document.createElement("div");
12
+ return r.setAttribute(k, ""), r.style.position = "relative", r.style.width = "100%", r.style.height = "100%", r.style.overflow = "hidden", e.appendChild(r), r;
13
+ }
14
+ function oe(e, c) {
15
+ const r = (u) => {
16
+ const o = u.detail;
17
+ o && c({
18
+ percentage: o.percentage,
19
+ currentElement: o.currentElement,
20
+ scrollOffset: o.scrollOffset,
21
+ result: { element: o.result.element, offset: o.result.offset }
22
+ });
23
+ }, a = (u) => {
24
+ const o = u.detail;
25
+ o && c({
26
+ percentage: o.percentage,
27
+ currentElement: o.element,
28
+ scrollOffset: o.scrollOffset,
29
+ result: { element: o.element, offset: o.scrollOffset }
30
+ });
31
+ };
32
+ return e.addEventListener("cerious-viewport-change", r), e.addEventListener("viewport-change", a), () => {
33
+ e.removeEventListener("cerious-viewport-change", r), e.removeEventListener("viewport-change", a);
34
+ };
35
+ }
36
+ function ce(e, c) {
37
+ const r = typeof e == "number" ? e : typeof c == "number" ? c : void 0;
38
+ if (r === void 0 || Number.isNaN(r))
39
+ throw new Error("useCeriousScroll: provide `totalElements` or `items`.");
40
+ return Math.max(1, Math.floor(r));
41
+ }
42
+ function le(e) {
43
+ var q;
44
+ const c = s(null), r = s(null), a = s(/* @__PURE__ */ new Map()), u = s(null), [, o] = J((t) => t + 1, 0), [L, O] = K(null), p = s(e.renderItem), d = s(e.items ?? null), v = s(e.getItem), w = s(e.options), h = s(e.autoRender ?? !0), C = s(e.totalElements ?? null), b = s(e.onViewportChange), y = s(e.onMeasuredViewport), A = s(e.onReady);
45
+ p.current = e.renderItem, d.current = e.items ?? null, v.current = e.getItem, w.current = e.options, h.current = e.autoRender ?? !0, C.current = e.totalElements ?? null, b.current = e.onViewportChange, y.current = e.onMeasuredViewport, A.current = e.onReady;
46
+ const P = S((t) => {
47
+ const n = v.current;
48
+ if (n) return n(t);
49
+ const g = d.current;
50
+ return g ? g[t] : void 0;
51
+ }, []), l = S(() => {
52
+ var I;
53
+ const t = r.current;
54
+ if (!t) return null;
55
+ const { scroller: n, contentEl: g, container: R } = t, N = R.clientHeight || R.offsetHeight || 0, T = [], i = (f, E) => {
56
+ const m = document.createElement("div");
57
+ m.setAttribute(re, String(f)), m.innerHTML = ee(
58
+ Q(U, null, p.current(P(f), f))
59
+ ), E.appendChild(m), a.current.set(f, { el: E, mount: m }), T.push(m);
60
+ }, M = n.renderViewport(N, g, i), F = new Set(n.getRenderedIndices());
61
+ return a.current.forEach((f, E) => {
62
+ F.has(E) || a.current.delete(E);
63
+ }), T.forEach((f) => {
64
+ f.textContent = "";
65
+ }), Z(o), (I = y.current) == null || I.call(y, M), M;
66
+ }, []), $ = e.totalElements ?? ((q = e.items) == null ? void 0 : q.length) ?? null;
67
+ _(() => {
68
+ var I, f;
69
+ const t = c.current;
70
+ if (!t) return;
71
+ const n = ne(t), g = w.current ?? {}, R = g.onScroll, N = {
72
+ ...g,
73
+ onScroll: () => {
74
+ R == null || R(), h.current && l();
75
+ }
76
+ }, T = ce(C.current, ((I = d.current) == null ? void 0 : I.length) ?? null), i = new te(t, T, N);
77
+ u.current && (i.currentElement = Math.min(u.current.currentElement, T - 1), i.scrollOffset = u.current.scrollOffset, u.current = null), r.current = { scroller: i, contentEl: n, container: t }, O(i);
78
+ const M = oe(t, (E) => {
79
+ var m;
80
+ (m = b.current) == null || m.call(b, E);
81
+ });
82
+ (f = A.current) == null || f.call(A, i);
83
+ let F = 0;
84
+ return h.current && (F = requestAnimationFrame(() => l())), () => {
85
+ cancelAnimationFrame(F), M(), u.current = {
86
+ currentElement: i.currentElement,
87
+ scrollOffset: i.scrollOffset
88
+ }, a.current.clear(), n.textContent = "", i.detachScrollbar(t), i.dispose(), r.current = null, O(null);
89
+ };
90
+ }, [$, l]), _(() => {
91
+ if (!r.current || !h.current) return;
92
+ const t = requestAnimationFrame(() => l());
93
+ return () => cancelAnimationFrame(t);
94
+ }, [e.items, e.getItem]);
95
+ const B = S(
96
+ (t) => {
97
+ const n = r.current;
98
+ return n ? (n.scroller.jumpToElement(t), l()) : null;
99
+ },
100
+ [l]
101
+ ), D = S(
102
+ (t) => {
103
+ const n = r.current;
104
+ return n ? (n.scroller.handleScrollPercentage(t), l()) : null;
105
+ },
106
+ [l]
107
+ ), W = S(() => {
108
+ const t = r.current;
109
+ return t ? (t.scroller.reset(), l()) : null;
110
+ }, [l]), z = S(() => {
111
+ const t = r.current;
112
+ return t ? (t.scroller.clearAllCaches(), l()) : null;
113
+ }, [l]), j = d.current, H = !v.current && j != null ? j.length : null, V = [];
114
+ return a.current.forEach((t, n) => {
115
+ H !== null && n >= H || V.push(
116
+ x(p.current(P(n), n), t.mount, String(n))
117
+ );
118
+ }), {
119
+ containerRef: c,
120
+ portals: V,
121
+ scroller: L,
122
+ render: l,
123
+ jumpToElement: B,
124
+ scrollToPercentage: D,
125
+ reset: W,
126
+ recalculate: z
127
+ };
128
+ }
129
+ function se(e, c) {
130
+ const { className: r, style: a, "data-testid": u, ...o } = e, {
131
+ containerRef: L,
132
+ portals: O,
133
+ scroller: p,
134
+ render: d,
135
+ jumpToElement: v,
136
+ scrollToPercentage: w,
137
+ reset: h,
138
+ recalculate: C
139
+ } = le(o);
140
+ return Y(
141
+ c,
142
+ () => ({ scroller: p, render: d, jumpToElement: v, scrollToPercentage: w, reset: h, recalculate: C }),
143
+ [p, d, v, w, h, C]
144
+ ), /* @__PURE__ */ G(
145
+ "div",
146
+ {
147
+ ref: L,
148
+ className: r,
149
+ "data-testid": u,
150
+ style: { position: "relative", overflow: "hidden", ...a },
151
+ children: O
152
+ }
153
+ );
154
+ }
155
+ const de = X(se);
156
+ export {
157
+ de as CeriousScroll,
158
+ Ee as CeriousScrollEngine,
159
+ le as useCeriousScroll
160
+ };
@@ -0,0 +1,59 @@
1
+ import { ReactNode, RefObject } from 'react';
2
+ import { CeriousScroll as CeriousScrollEngine, CeriousScrollOptions, MeasuredViewportRange } from '@ceriousdevtech/cerious-scroll';
3
+ import { CeriousViewportChangeDetail } from './viewport-change';
4
+ export interface UseCeriousScrollOptions<TItem = unknown> {
5
+ /** Total number of items. Falls back to `items.length` when omitted. */
6
+ totalElements?: number | null;
7
+ /** Optional items array (passed to `renderItem` as the first argument). */
8
+ items?: readonly TItem[] | null;
9
+ /** Optional getter for very large/sparse datasets (alternative to `items`). */
10
+ getItem?: (index: number) => TItem;
11
+ /** Renders a single row. `item` is `undefined` when no data source is given. */
12
+ renderItem: (item: TItem, index: number) => ReactNode;
13
+ /** Options forwarded to `new CeriousScroll(...)` (read once, at creation). */
14
+ options?: CeriousScrollOptions;
15
+ /** Automatically render after scroll/resize/data changes. Default: `true`. */
16
+ autoRender?: boolean;
17
+ /** Invoked with the normalized viewport-change payload. */
18
+ onViewportChange?: (detail: CeriousViewportChangeDetail) => void;
19
+ /** Invoked with the measured range after each render pass. */
20
+ onMeasuredViewport?: (range: MeasuredViewportRange) => void;
21
+ /** Invoked once the underlying engine instance is ready (and after recreation). */
22
+ onReady?: (scroller: CeriousScrollEngine) => void;
23
+ }
24
+ export interface UseCeriousScrollResult {
25
+ /** Attach to the scroll container element. */
26
+ containerRef: RefObject<HTMLDivElement>;
27
+ /** React portals for the currently rendered rows. Render these in your tree. */
28
+ portals: ReactNode;
29
+ /** The underlying engine instance (`null` before mount / after unmount). */
30
+ scroller: CeriousScrollEngine | null;
31
+ /** Imperatively trigger a render pass. */
32
+ render: () => MeasuredViewportRange | null;
33
+ /** Jump to an element index, then render. */
34
+ jumpToElement: (index: number) => MeasuredViewportRange | null;
35
+ /** Scroll to a percentage (0..100), then render. */
36
+ scrollToPercentage: (percentage: number) => MeasuredViewportRange | null;
37
+ /** Reset to the top, then render. */
38
+ reset: () => MeasuredViewportRange | null;
39
+ /**
40
+ * Discard all cached row heights and re-measure the viewport.
41
+ *
42
+ * Call this only when the heights of rows you've *already rendered* may have
43
+ * changed without their indices changing — e.g. a global font/density change,
44
+ * or swapping every row to a different layout. This forces a synchronous
45
+ * re-measure (one `offsetHeight` read per visible row), so do NOT call it on
46
+ * routine edits: a single cell edit doesn't need it (its row keeps its size,
47
+ * and the engine's ResizeObserver picks up any incidental resize on its own).
48
+ */
49
+ recalculate: () => MeasuredViewportRange | null;
50
+ }
51
+ /**
52
+ * Bind a CeriousScroll engine to a container and render rows as React portals.
53
+ *
54
+ * Each row is rendered into an inner mount node that lives inside the engine's
55
+ * recyclable container and is committed synchronously via `flushSync`, so the
56
+ * engine measures the row's real height (no estimation). Rows stay in your
57
+ * React tree, so Context/providers work as usual.
58
+ */
59
+ export declare function useCeriousScroll<TItem = unknown>(opts: UseCeriousScrollOptions<TItem>): UseCeriousScrollResult;
@@ -0,0 +1,25 @@
1
+ import { ScrollResult } from '@ceriousdevtech/cerious-scroll';
2
+ /**
3
+ * Normalized payload for viewport changes emitted by CeriousScroll.
4
+ *
5
+ * Mirrors the `cerious-viewport-change` CustomEvent dispatched on wheel/touch/
6
+ * keyboard navigation, and normalizes the native scrollbar's `viewport-change`
7
+ * event into the same shape.
8
+ */
9
+ export interface CeriousViewportChangeDetail {
10
+ /** Scroll percentage from 0..100. */
11
+ percentage: number;
12
+ /** Current top-most element index tracked by CeriousScroll. */
13
+ currentElement: number;
14
+ /** Pixel offset within `currentElement`. */
15
+ scrollOffset: number;
16
+ /** Scroll operation result (element + offset). */
17
+ result: ScrollResult;
18
+ }
19
+ /**
20
+ * Subscribe to both viewport-change events emitted by CeriousScroll and invoke
21
+ * `callback` with a normalized {@link CeriousViewportChangeDetail}.
22
+ *
23
+ * @returns An unsubscribe function.
24
+ */
25
+ export declare function subscribeViewportChange(container: HTMLElement, callback: (detail: CeriousViewportChangeDetail) => void): () => void;
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "@ceriousdevtech/react-cerious-scroll",
3
+ "version": "1.0.1",
4
+ "description": "React bindings for CeriousScroll — high-performance virtual scrolling with O(1) memory and no height estimation",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ },
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "LICENSE",
20
+ "README.md",
21
+ "CHANGELOG.md"
22
+ ],
23
+ "sideEffects": false,
24
+ "license": "MIT",
25
+ "author": "Cerious DevTech LLC <info@ceriousdevtech.com>",
26
+ "homepage": "https://github.com/ceriousdevtech/react-cerious-scroll#readme",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/ceriousdevtech/react-cerious-scroll.git"
30
+ },
31
+ "bugs": {
32
+ "url": "https://github.com/ceriousdevtech/react-cerious-scroll/issues",
33
+ "email": "info@ceriousdevtech.com"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "keywords": [
39
+ "react",
40
+ "virtual-scroll",
41
+ "virtual-scrolling",
42
+ "infinite-scroll",
43
+ "performance",
44
+ "large-lists",
45
+ "data-grid",
46
+ "variable-height",
47
+ "o1-memory",
48
+ "cerious-scroll"
49
+ ],
50
+ "scripts": {
51
+ "build": "vite build",
52
+ "test": "vitest run",
53
+ "test:watch": "vitest",
54
+ "typecheck": "tsc --noEmit",
55
+ "demo": "vite --config demo/vite.config.ts",
56
+ "demo:build": "vite build --config demo/vite.config.ts"
57
+ },
58
+ "peerDependencies": {
59
+ "@ceriousdevtech/cerious-scroll": "^1.0.1",
60
+ "react": ">=18 <20",
61
+ "react-dom": ">=18 <20"
62
+ },
63
+ "devDependencies": {
64
+ "@testing-library/dom": "^10.4.0",
65
+ "@testing-library/react": "^16.0.1",
66
+ "@types/node": "^22.10.2",
67
+ "@types/react": "^18.3.12",
68
+ "@types/react-dom": "^18.3.1",
69
+ "@vitejs/plugin-react": "^4.3.4",
70
+ "jsdom": "^25.0.1",
71
+ "react": "^18.3.1",
72
+ "react-dom": "^18.3.1",
73
+ "react-router-dom": "^6.30.4",
74
+ "typescript": "^5.6.3",
75
+ "vite": "^5.4.11",
76
+ "vite-plugin-dts": "^4.3.0",
77
+ "vitest": "^2.1.8"
78
+ }
79
+ }