Add useSizeObserver and replace most react-measure
This commit is contained in:
parent
7267391de4
commit
6c70cd450b
20 changed files with 539 additions and 421 deletions
188
ts/hooks/useSizeObserver.tsx
Normal file
188
ts/hooks/useSizeObserver.tsx
Normal file
|
@ -0,0 +1,188 @@
|
|||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import type { RefObject } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { strictAssert } from '../util/assert';
|
||||
|
||||
export type Size = Readonly<{
|
||||
width: number;
|
||||
height: number;
|
||||
}>;
|
||||
|
||||
export type SizeChangeHandler = (size: Size) => void;
|
||||
|
||||
export function isSameSize(a: Size, b: Size): boolean {
|
||||
return a.width === b.width && a.height === b.height;
|
||||
}
|
||||
|
||||
export function useSizeObserver<T extends Element = Element>(
|
||||
ref: RefObject<T>,
|
||||
/**
|
||||
* Note: If you provide `onSizeChange`, `useSizeObserver()` will always return `null`
|
||||
*/
|
||||
onSizeChange?: SizeChangeHandler
|
||||
): Size | null {
|
||||
const [size, setSize] = useState<Size | null>(null);
|
||||
const sizeRef = useRef<Size | null>(null);
|
||||
const onSizeChangeRef = useRef<SizeChangeHandler | void>(onSizeChange);
|
||||
useEffect(() => {
|
||||
// This means you don't need to wrap `onSizeChange` with `useCallback()`
|
||||
onSizeChangeRef.current = onSizeChange;
|
||||
}, [onSizeChange]);
|
||||
useEffect(() => {
|
||||
const observer = new ResizeObserver(entries => {
|
||||
// It's possible that ResizeObserver emit entries after disconnect()
|
||||
if (ref.current == null) {
|
||||
return;
|
||||
}
|
||||
// We're only ever observing one element, and `ResizeObserver` for some
|
||||
// reason is an array of exactly one rect (I assume to support wrapped
|
||||
// inline elements in the future)
|
||||
const borderBoxSize = entries[0].borderBoxSize[0];
|
||||
// We are assuming a horizontal writing-mode here, we could call
|
||||
// `getBoundingClientRect()` here but MDN says not to. In the future if
|
||||
// we are adding support for a vertical locale we may need to change this
|
||||
const next: Size = {
|
||||
width: borderBoxSize.inlineSize,
|
||||
height: borderBoxSize.blockSize,
|
||||
};
|
||||
const prev = sizeRef.current;
|
||||
if (prev == null || !isSameSize(prev, next)) {
|
||||
sizeRef.current = next;
|
||||
if (onSizeChangeRef.current != null) {
|
||||
onSizeChangeRef.current(next);
|
||||
} else {
|
||||
setSize(next);
|
||||
}
|
||||
}
|
||||
});
|
||||
strictAssert(
|
||||
ref.current instanceof Element,
|
||||
'ref must be assigned to an element'
|
||||
);
|
||||
observer.observe(ref.current, {
|
||||
box: 'border-box',
|
||||
});
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [ref]);
|
||||
return size;
|
||||
}
|
||||
|
||||
// Note we use `any` for ref below because TypeScript doesn't currently have
|
||||
// good inference for JSX generics and it creates confusing errors. We have
|
||||
// a better error being reported by the hook.
|
||||
|
||||
export type SizeObserverProps = Readonly<{
|
||||
/**
|
||||
* Note: If you provide `onSizeChange`, in `children()` the `size` will always be `null`
|
||||
*/
|
||||
onSizeChange?: SizeChangeHandler;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
children(ref: RefObject<any>, size: Size | null): JSX.Element;
|
||||
}>;
|
||||
|
||||
export function SizeObserver({
|
||||
onSizeChange,
|
||||
children,
|
||||
}: SizeObserverProps): JSX.Element {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const ref = useRef<any>();
|
||||
const size = useSizeObserver(ref, onSizeChange);
|
||||
return children(ref, size);
|
||||
}
|
||||
|
||||
export type Scroll = Readonly<{
|
||||
scrollTop: number;
|
||||
scrollHeight: number;
|
||||
clientHeight: number;
|
||||
}>;
|
||||
|
||||
export type ScrollChangeHandler = (scroll: Scroll) => void;
|
||||
|
||||
export function isSameScroll(a: Scroll, b: Scroll): boolean {
|
||||
return (
|
||||
a.scrollTop === b.scrollTop &&
|
||||
a.scrollHeight === b.scrollHeight &&
|
||||
a.clientHeight === b.clientHeight
|
||||
);
|
||||
}
|
||||
|
||||
export function isOverflowing(scroll: Scroll): boolean {
|
||||
return scroll.scrollHeight > scroll.clientHeight;
|
||||
}
|
||||
|
||||
export function isScrolled(scroll: Scroll): boolean {
|
||||
return scroll.scrollTop > 0;
|
||||
}
|
||||
|
||||
export function isScrolledToBottom(scroll: Scroll, threshold = 0): boolean {
|
||||
const maxScrollTop = scroll.scrollHeight - scroll.clientHeight;
|
||||
return scroll.scrollTop >= maxScrollTop - threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* We need an extra element because there is no ResizeObserver equivalent for
|
||||
* `scrollHeight`. You need something measuring the scroll container and an
|
||||
* inner element wrapping all of its children.
|
||||
*
|
||||
* ```
|
||||
* const scrollerRef = useRef()
|
||||
* const scrollerInnerRef = useRef()
|
||||
*
|
||||
* useScrollObserver(scrollerRef, scrollerInnerRef, (scroll) => {
|
||||
* setIsOverflowing(isOverflowing(scroll));
|
||||
* setIsScrolled(isScrolled(scroll));
|
||||
* setAtBottom(isScrolledToBottom(scroll));
|
||||
* })
|
||||
*
|
||||
* <div ref={scrollerRef} style={{ overflow: "auto" }}>
|
||||
* <div ref={scrollerInnerRef}>
|
||||
* {children}
|
||||
* </div>
|
||||
* </div>
|
||||
* ```
|
||||
*/
|
||||
export function useScrollObserver(
|
||||
scrollerRef: RefObject<HTMLElement>,
|
||||
scrollerInnerRef: RefObject<HTMLElement>,
|
||||
onScrollChange: (scroll: Scroll) => void
|
||||
): void {
|
||||
const scrollRef = useRef<Scroll | null>(null);
|
||||
const onScrollChangeRef = useRef<ScrollChangeHandler>(onScrollChange);
|
||||
useEffect(() => {
|
||||
// This means you don't need to wrap `onScrollChange` with `useCallback()`
|
||||
onScrollChangeRef.current = onScrollChange;
|
||||
}, [onScrollChange]);
|
||||
const onUpdate = useCallback(() => {
|
||||
const target = scrollerRef.current;
|
||||
strictAssert(
|
||||
target instanceof Element,
|
||||
'ref must be assigned to an element'
|
||||
);
|
||||
const next: Scroll = {
|
||||
scrollTop: target.scrollTop,
|
||||
scrollHeight: target.scrollHeight,
|
||||
clientHeight: target.clientHeight,
|
||||
};
|
||||
const prev = scrollRef.current;
|
||||
if (prev == null || !isSameScroll(prev, next)) {
|
||||
scrollRef.current = next;
|
||||
onScrollChangeRef.current(next);
|
||||
}
|
||||
}, [scrollerRef]);
|
||||
useSizeObserver(scrollerRef, onUpdate);
|
||||
useSizeObserver(scrollerInnerRef, onUpdate);
|
||||
useEffect(() => {
|
||||
strictAssert(
|
||||
scrollerRef.current instanceof Element,
|
||||
'ref must be assigned to an element'
|
||||
);
|
||||
const target = scrollerRef.current;
|
||||
target.addEventListener('scroll', onUpdate, { passive: true });
|
||||
return () => {
|
||||
target.removeEventListener('scroll', onUpdate);
|
||||
};
|
||||
}, [scrollerRef, onUpdate]);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue