Skip to content

Commit

Permalink
feat(toolkit-react): ✨ create core version of useScrollObserver hook …
Browse files Browse the repository at this point in the history
…for scroll detection
  • Loading branch information
Ryan-Zayne committed Mar 1, 2025
1 parent 878752d commit e1ea64d
Show file tree
Hide file tree
Showing 7 changed files with 87 additions and 171 deletions.
39 changes: 24 additions & 15 deletions apps/dev/src/client/AnotherApp.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,37 @@
import { useResource } from "@zayne-labs/toolkit-react";
import { useScrollObserver } from "@zayne-labs/toolkit-react";

function AnotherApp() {
const resource = useResource({
fn: () => fetch("https://jsonplaceholder.typicode.com/todos/1").then((res) => res.json()),
});
const { isScrolled, observedElementRef } = useScrollObserver();

Check failure on line 4 in apps/dev/src/client/AnotherApp.tsx

View workflow job for this annotation

GitHub Actions / lint-and-type (20.x)

Unsafe assignment of an error typed value

Check failure on line 4 in apps/dev/src/client/AnotherApp.tsx

View workflow job for this annotation

GitHub Actions / lint-and-type (20.x)

Unsafe call of a(n) `error` type typed value

console.info(resource);
console.info({ isScrolled });

Check failure on line 6 in apps/dev/src/client/AnotherApp.tsx

View workflow job for this annotation

GitHub Actions / lint-and-type (20.x)

Unsafe assignment of an error typed value

return (

Check failure on line 8 in apps/dev/src/client/AnotherApp.tsx

View workflow job for this annotation

GitHub Actions / lint-and-type (20.x)

Unsafe return of a value of type error
<>
<div>
<style>
{`
.btn {
background-color: red;
margin-top: 10px;
@scope {
button {
background-color: red;
margin-top: 10px;
}
header {
position: sticky;
top: 0;
height: 100px;
background-color: blue;
width: 100%;
margin-bottom: 10000px;
}
}
`}
</style>
<div>
<button className="btn" type="button">
Force Render
</button>
</div>
</>
<header ref={observedElementRef}>Header</header>

Check failure on line 29 in apps/dev/src/client/AnotherApp.tsx

View workflow job for this annotation

GitHub Actions / lint-and-type (20.x)

Unsafe assignment of an error typed value
<div />
<button className="btn" type="button">
Force Render
</button>
</div>
);
}
export default AnotherApp;
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
"packageManager": "[email protected]",
"author": "Ryan Zayne",
"scripts": {
"build": "pnpm --filter ./packages/* build",
"build": "pnpm --filter \"./packages/*\" build",
"build:dev": "pnpm --filter ./packages/* build:dev",
"build:test": "pnpm --filter ./packages/* build:test",
"build:test": "pnpm --filter \"./packages/*\" build:test",
"bump": "bumpp",
"dev": "pnpm --filter ./packages/* dev",
"inspect:eslint-config": "pnpx @eslint/config-inspector@latest",
Expand Down
2 changes: 1 addition & 1 deletion packages/toolkit-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@
"size-limit": [
{
"path": "./src/index.ts",
"limit": "3.6 kb"
"limit": "3.7 kb"
}
]
}
127 changes: 0 additions & 127 deletions packages/toolkit-core/src/createDragScroll.ts

This file was deleted.

38 changes: 38 additions & 0 deletions packages/toolkit-core/src/createScrollObserver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { isBrowser } from "./constants";

export type ScrollObserverOptions = IntersectionObserverInit & {
onIntersection?: (entry: IntersectionObserverEntry, observer: IntersectionObserver) => void;
};

export const createScrollObserver = <TElement extends HTMLElement>(
options: ScrollObserverOptions = {}
) => {
const { rootMargin = "10px 0px 0px 0px", ...restOfOptions } = options;

const elementObserver = isBrowser()
? new IntersectionObserver(
(entries, observer) => entries.forEach((entry) => options.onIntersection?.(entry, observer)),
{ rootMargin, ...restOfOptions }
)
: null;

const handleObservation = (element: TElement | null) => {
const scrollWatcher = document.createElement("span");
scrollWatcher.dataset.scrollWatcher = "";

element?.before(scrollWatcher);

if (!elementObserver) return;

elementObserver.observe(scrollWatcher);

const cleanupFn = () => {
scrollWatcher.remove();
elementObserver.disconnect();
};

return cleanupFn;
};

return { elementObserver, handleObservation };
};
1 change: 1 addition & 0 deletions packages/toolkit-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from "./constants";
export { copyToClipboard } from "./copyToClipboard";
export * from "./createExternalStore";
export * from "./createLocationStore";
export * from "./createScrollObserver";
export * from "./createStore";
export { debounce } from "./debounce";
export * from "./handleFileValidation";
Expand Down
47 changes: 21 additions & 26 deletions packages/toolkit-react/src/hooks/useScrollObserver.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,38 @@
import { isBrowser } from "@zayne-labs/toolkit-core";
import { type ScrollObserverOptions, createScrollObserver } from "@zayne-labs/toolkit-core";
import { type RefCallback, useState } from "react";
import { useCallbackRef } from "./useCallbackRef";
import { useConstant } from "./useConstant";

const useScrollObserver = <TElement extends HTMLElement>(options: IntersectionObserverInit = {}) => {
const { rootMargin = "10px 0px 0px 0px", ...restOfOptions } = options;
const useScrollObserver = <TElement extends HTMLElement>(options: ScrollObserverOptions = {}) => {
const { onIntersection, rootMargin = "10px 0px 0px 0px", ...restOfOptions } = options;

const [isScrolled, setIsScrolled] = useState(false);

const elementObserver = useConstant(() => {
if (!isBrowser()) return;
const savedOnIntersection = useCallbackRef(onIntersection);

return new IntersectionObserver(
([entry]) => {
if (!entry) return;
setIsScrolled(!entry.isIntersecting);
},
{ rootMargin, ...restOfOptions }
);
});

const observedElementRef: RefCallback<TElement> = useCallbackRef((element) => {
const scrollWatcher = document.createElement("span");
scrollWatcher.dataset.scrollWatcher = "";
const { handleObservation } = useConstant(() => {
return createScrollObserver({
onIntersection: (entry, observer) => {
const newIsScrolledState = !entry.isIntersecting;

element?.before(scrollWatcher);
setIsScrolled(newIsScrolledState);

if (!elementObserver) return;
// eslint-disable-next-line no-param-reassign -- Mutation is fine here
(entry.target as HTMLElement).dataset.scrolled = String(newIsScrolledState);

elementObserver.observe(scrollWatcher);
savedOnIntersection(entry, observer);
},
rootMargin,
...restOfOptions,
});
});

const cleanupFn = () => {
scrollWatcher.remove();
elementObserver.disconnect();
};
const observedElementRef: RefCallback<TElement> = useCallbackRef((element) => {
const cleanupFn = handleObservation(element);

// React 18 may not call the cleanup function so we need to call it manually on element unmount
// == React 18 may not call the cleanup function so we need to call it manually on element unmount
if (!element) {
cleanupFn();
cleanupFn?.();
return;
}

Expand Down

0 comments on commit e1ea64d

Please sign in to comment.