@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 +27 -0
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/dist/cerious-scroll.d.ts +35 -0
- package/dist/content-element.d.ts +18 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +160 -0
- package/dist/use-cerious-scroll.d.ts +59 -0
- package/dist/viewport-change.d.ts +25 -0
- package/package.json +79 -0
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)
|
|
4
|
+
[](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;
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|