overlayscrollbars-react v0.5.0

This commit is contained in:
Rene Haas
2022-11-16 15:45:06 +01:00
parent 8f9642d78a
commit 3667cd725c
8 changed files with 180 additions and 49 deletions
+14 -2
View File
@@ -1,5 +1,17 @@
# Changelog # Changelog
## 0.5.0
### Features
- `OverlayScrollbarsComponent` accepts now the `defer` property
- `useOverlayScrollbars` params accept now the `defer` key
- `useOverlayScrollbars` will now always try to destroy the instance if the component unmounts.
### Breaking Changes
- Because initialization can be deferred now, the `initialize` function of the `useOverlayScrollbars` hook isn't returning the instance anymore. Use the `instance` function of the `useOverlayScrollbars` hook instead.
## 0.4.0 ## 0.4.0
Depends on `OverlayScrollbars` version `^2.0.0` and `React` version `>=16.8.0`. Depends on `OverlayScrollbars` version `^2.0.0` and `React` version `>=16.8.0`.
@@ -7,8 +19,8 @@ The component was rewritten using `hooks`. ([#218](https://github.com/KingSora/O
### Features ### Features
- `OverlayScrollbarsComponent` has now the `events` property - `OverlayScrollbarsComponent` accepts now the `events` property
- `OverlayScrollbarsComponent` has now the `element` property - `OverlayScrollbarsComponent` accepts now the `element` property
- The `useOverlayScrollbars` hook was added for advanced usage - The `useOverlayScrollbars` hook was added for advanced usage
### Breaking Changes ### Breaking Changes
+11 -5
View File
@@ -57,7 +57,7 @@ import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
// ... // ...
<OverlayScrollbarsComponent> <OverlayScrollbarsComponent defer>
example content example content
</OverlayScrollbarsComponent> </OverlayScrollbarsComponent>
``` ```
@@ -70,12 +70,16 @@ Additionally it has three optional properties: `element`, `options` and `events`
- `element`: accepts a `string` which represents the tag of the root element. - `element`: accepts a `string` which represents the tag of the root element.
- `options`: accepts an `object` which represents the OverlayScrollbars options. - `options`: accepts an `object` which represents the OverlayScrollbars options.
- `events`: accepts an `object` which represents the OverlayScrollbars events. - `events`: accepts an `object` which represents the OverlayScrollbars events.
- `defer`: accepts an `boolean` or `object`. Defers the initialization to a point in time when the browser is idle.
> __Note__: None of the properties has to be memoized. > __Note__: None of the properties has to be memoized.
> __Note__: Its **highly recommended** to use the `defer` option whenever possible to defer the initialization of the component to a browser's idle period.
```jsx ```jsx
// example usage // example usage
<OverlayScrollbarsComponent <OverlayScrollbarsComponent
defer
element="span" element="span"
options={{ scrollbars: { autoHide: 'scroll' } }} options={{ scrollbars: { autoHide: 'scroll' } }}
events={{ scroll: () => { /* ... */ } }} events={{ scroll: () => { /* ... */ } }}
@@ -100,11 +104,10 @@ import { useOverlayScrollbars } from "overlayscrollbars-react";
// example usage // example usage
const Component = () => { const Component = () => {
const ref = useRef(); const ref = useRef();
const [initialize, instance] = useOverlayScrollbars({ options, events }); const [initialize, instance] = useOverlayScrollbars({ options, events, defer });
useEffect(() => { useEffect(() => {
const osInstance = initialize(ref.current); initialize(ref.current);
return () => osInstance.destroy();
}, [initialize]); }, [initialize]);
return <div ref={ref} /> return <div ref={ref} />
@@ -113,6 +116,8 @@ const Component = () => {
The hook is for advanced usage and lets you control the whole initialization process. This is useful if you want to integrate it with other plugins such as `react-window` or `react-virtualized`. The hook is for advanced usage and lets you control the whole initialization process. This is useful if you want to integrate it with other plugins such as `react-window` or `react-virtualized`.
The hook will destroy the instance automatically if the component unmounts.
### Parameters ### Parameters
Parameters are optional and similar to the `OverlayScrollbarsComponent`. Parameters are optional and similar to the `OverlayScrollbarsComponent`.
@@ -120,12 +125,13 @@ Its an `object` with two optional properties:
- `options`: accepts an `object` which represents the OverlayScrollbars options. - `options`: accepts an `object` which represents the OverlayScrollbars options.
- `events`: accepts an `object` which represents the OverlayScrollbars events. - `events`: accepts an `object` which represents the OverlayScrollbars events.
- `defer`: accepts an `boolean` or `object`. Defers the initialization to a point in time when the browser is idle.
### Return ### Return
The `useOverlayScrollbars` hook returns a `tuple` with two values: The `useOverlayScrollbars` hook returns a `tuple` with two values:
- The first value is the `initialization` function, it takes one argument which is the `InitializationTarget` and returns the OverlayScrollbars instance. - The first value is the `initialization` function, it takes one argument which is the `InitializationTarget`.
- The second value is a function which returns the current OverlayScrollbars instance or `null` if not initialized. - The second value is a function which returns the current OverlayScrollbars instance or `null` if not initialized.
> __Note__: The identity of both functions is stable and won't change, thus they can safely be used in any dependency array. > __Note__: The identity of both functions is stable and won't change, thus they can safely be used in any dependency array.
@@ -1,7 +1,7 @@
{ {
"name": "overlayscrollbars-react", "name": "overlayscrollbars-react",
"private": true, "private": true,
"version": "0.4.0", "version": "0.5.0",
"description": "OverlayScrollbars for React.", "description": "OverlayScrollbars for React.",
"author": "Rene Haas | KingSora", "author": "Rene Haas | KingSora",
"license": "MIT", "license": "MIT",
@@ -12,6 +12,8 @@ export type OverlayScrollbarsComponentProps<T extends keyof JSX.IntrinsicElement
options?: PartialOptions | false | null; options?: PartialOptions | false | null;
/** OverlayScrollbars events. */ /** OverlayScrollbars events. */
events?: EventListeners | false | null; events?: EventListeners | false | null;
/** Whether to defer the initialization to a point in time when the browser is idle. (or to the next frame if `window.requestIdleCallback` is unsupported) */
defer?: boolean | IdleRequestOptions;
}; };
export interface OverlayScrollbarsComponentRef<T extends keyof JSX.IntrinsicElements = 'div'> { export interface OverlayScrollbarsComponentRef<T extends keyof JSX.IntrinsicElements = 'div'> {
@@ -25,26 +27,25 @@ const OverlayScrollbarsComponent = <T extends keyof JSX.IntrinsicElements>(
props: OverlayScrollbarsComponentProps<T>, props: OverlayScrollbarsComponentProps<T>,
ref: ForwardedRef<OverlayScrollbarsComponentRef<T>> ref: ForwardedRef<OverlayScrollbarsComponentRef<T>>
) => { ) => {
const { element = 'div', options, events, children, ...other } = props; const { element = 'div', options, events, defer, children, ...other } = props;
const Tag = element; const Tag = element;
const elementRef = useRef<ElementRef<T>>(null); const elementRef = useRef<ElementRef<T>>(null);
const childrenRef = useRef<HTMLDivElement>(null); const childrenRef = useRef<HTMLDivElement>(null);
const [initialize, osInstance] = useOverlayScrollbars({ options, events }); const [initialize, osInstance] = useOverlayScrollbars({ options, events, defer });
useEffect(() => { useEffect(() => {
const { current: elm } = elementRef; const { current: elm } = elementRef;
const { current: childrenElm } = childrenRef; const { current: childrenElm } = childrenRef;
if (elm && childrenElm) { if (elm && childrenElm) {
const instance = initialize({ initialize({
target: elm as any, target: elm as any,
elements: { elements: {
viewport: childrenElm, viewport: childrenElm,
content: childrenElm, content: childrenElm,
}, },
}); });
return () => instance.destroy();
} }
return () => osInstance()?.destroy();
}, [initialize, element]); }, [initialize, element]);
useImperativeHandle( useImperativeHandle(
@@ -6,36 +6,88 @@ import type {
OverlayScrollbarsComponentRef, OverlayScrollbarsComponentRef,
} from './OverlayScrollbarsComponent'; } from './OverlayScrollbarsComponent';
type Defer = [
requestDefer: (callback: () => any, options?: OverlayScrollbarsComponentProps['defer']) => void,
cancelDefer: () => void
];
export interface UseOverlayScrollbarsParams { export interface UseOverlayScrollbarsParams {
/** OverlayScrollbars options. */ /** OverlayScrollbars options. */
options?: OverlayScrollbarsComponentProps['options']; options?: OverlayScrollbarsComponentProps['options'];
/** OverlayScrollbars events. */ /** OverlayScrollbars events. */
events?: OverlayScrollbarsComponentProps['events']; events?: OverlayScrollbarsComponentProps['events'];
/** Whether to defer the initialization to a point in time when the browser is idle. (or to the next frame if `window.requestIdleCallback` is unsupported) */
defer?: OverlayScrollbarsComponentProps['defer'];
} }
export type UseOverlayScrollbarsInitialization = ( export type UseOverlayScrollbarsInitialization = (target: InitializationTarget) => void;
target: InitializationTarget
) => OverlayScrollbars;
export type UseOverlayScrollbarsInstance = () => ReturnType< export type UseOverlayScrollbarsInstance = () => ReturnType<
OverlayScrollbarsComponentRef['osInstance'] OverlayScrollbarsComponentRef['osInstance']
>; >;
const createDefer = (): Defer => {
/* c8 ignore start */
if (typeof window === 'undefined') {
// mock ssr calls with "noop"
// eslint-disable-next-line @typescript-eslint/no-empty-function
const noop = () => {};
return [noop, noop];
}
/* c8 ignore end */
let idleId: number;
let rafId: number;
const wnd = window;
const idleSupported = typeof wnd.requestIdleCallback === 'function';
const rAF = wnd.requestAnimationFrame;
const cAF = wnd.cancelAnimationFrame;
const rIdle = idleSupported ? wnd.requestIdleCallback : rAF;
const cIdle = idleSupported ? wnd.cancelIdleCallback : cAF;
const clear = () => {
cIdle(idleId);
cAF(rafId);
};
return [
(callback, options) => {
clear();
idleId = rIdle(
idleSupported
? () => {
clear();
// inside idle its best practice to use rAF to change DOM for best performance
rafId = rAF(callback);
}
: callback,
typeof options === 'object' ? options : { timeout: 1500 }
);
},
clear,
];
};
/** /**
* Hook for advanced usage of OverlayScrollbars. (When the OverlayScrollbarsComponent is not enough) * Hook for advanced usage of OverlayScrollbars. (When the OverlayScrollbarsComponent is not enough)
* @param params Parameters for customization. * @param params Parameters for customization.
* @returns A tuple with two values: * @returns A tuple with two values:
* The first value is the initialization function, it takes one argument which is the `InitializationTarget` and returns the OverlayScrollbars instance. * The first value is the initialization function, it takes one argument which is the `InitializationTarget`.
* The second value is a function which returns the current OverlayScrollbars instance or `null` if not initialized. * The second value is a function which returns the current OverlayScrollbars instance or `null` if not initialized.
*/ */
export const useOverlayScrollbars = ( export const useOverlayScrollbars = (
params?: UseOverlayScrollbarsParams params?: UseOverlayScrollbarsParams
): [UseOverlayScrollbarsInitialization, UseOverlayScrollbarsInstance] => { ): [UseOverlayScrollbarsInitialization, UseOverlayScrollbarsInstance] => {
const { options, events } = params || {}; const { options, events, defer } = params || {};
const [requestDefer, cancelDefer] = useMemo<Defer>(createDefer, []);
const instanceRef = useRef<ReturnType<UseOverlayScrollbarsInstance>>(null); const instanceRef = useRef<ReturnType<UseOverlayScrollbarsInstance>>(null);
const deferRef = useRef(defer);
const optionsRef = useRef(options); const optionsRef = useRef(options);
const eventsRef = useRef(events); const eventsRef = useRef(events);
useEffect(() => {
deferRef.current = defer;
}, [defer]);
useEffect(() => { useEffect(() => {
const { current: instance } = instanceRef; const { current: instance } = instanceRef;
@@ -56,24 +108,34 @@ export const useOverlayScrollbars = (
} }
}, [events]); }, [events]);
useEffect(
() => () => {
cancelDefer();
instanceRef.current?.destroy();
},
[]
);
return useMemo<[UseOverlayScrollbarsInitialization, UseOverlayScrollbarsInstance]>( return useMemo<[UseOverlayScrollbarsInitialization, UseOverlayScrollbarsInstance]>(
() => [ () => [
(target: InitializationTarget): OverlayScrollbars => { (target) => {
// if already initialized return the current instance // if already initialized do nothing
const presentInstance = instanceRef.current; const presentInstance = instanceRef.current;
if (OverlayScrollbars.valid(presentInstance)) { if (OverlayScrollbars.valid(presentInstance)) {
return presentInstance; return;
} }
const currDefer = deferRef.current;
const currOptions = optionsRef.current || {}; const currOptions = optionsRef.current || {};
const currEvents = eventsRef.current || {}; const currEvents = eventsRef.current || {};
const osInstance = (instanceRef.current = OverlayScrollbars( const init = () =>
target, (instanceRef.current = OverlayScrollbars(target, currOptions, currEvents));
currOptions,
currEvents
));
return osInstance; if (currDefer) {
requestDefer(init, currDefer);
} else {
init();
}
}, },
() => instanceRef.current, () => instanceRef.current,
], ],
@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { describe, test, afterEach, expect, vitest } from 'vitest'; import { describe, test, afterEach, expect, vitest, vi } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react'; import { render, screen, cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { OverlayScrollbars } from 'overlayscrollbars'; import { OverlayScrollbars } from 'overlayscrollbars';
@@ -7,6 +7,15 @@ import { OverlayScrollbarsComponent } from '~/overlayscrollbars-react';
import type { RefObject } from 'react'; import type { RefObject } from 'react';
import type { OverlayScrollbarsComponentRef } from '~/overlayscrollbars-react'; import type { OverlayScrollbarsComponentRef } from '~/overlayscrollbars-react';
vi.useFakeTimers({
toFake: [
'requestAnimationFrame',
'cancelAnimationFrame',
'requestIdleCallback',
'cancelIdleCallback',
],
});
describe('OverlayScrollbarsComponent', () => { describe('OverlayScrollbarsComponent', () => {
afterEach(() => cleanup()); afterEach(() => cleanup());
@@ -118,6 +127,44 @@ describe('OverlayScrollbarsComponent', () => {
}); });
}); });
describe('deferred initialization', () => {
test('basic defer', () => {
const { container } = render(<OverlayScrollbarsComponent defer />);
expect(OverlayScrollbars(container.firstElementChild! as HTMLElement)).toBeUndefined();
vi.advanceTimersByTime(2000);
expect(OverlayScrollbars(container.firstElementChild! as HTMLElement)).toBeDefined();
});
test('options defer', () => {
const { container } = render(<OverlayScrollbarsComponent defer={{ timeout: 0 }} />);
expect(OverlayScrollbars(container.firstElementChild! as HTMLElement)).toBeUndefined();
vi.advanceTimersByTime(2000);
expect(OverlayScrollbars(container.firstElementChild! as HTMLElement)).toBeDefined();
});
test('defer with unsupported Idle', () => {
const original = window.requestIdleCallback;
// @ts-ignore
window.requestIdleCallback = undefined;
const { container } = render(<OverlayScrollbarsComponent defer />);
expect(OverlayScrollbars(container.firstElementChild! as HTMLElement)).toBeUndefined();
vi.advanceTimersByTime(2000);
expect(OverlayScrollbars(container.firstElementChild! as HTMLElement)).toBeDefined();
window.requestIdleCallback = original;
});
});
test('ref', () => { test('ref', () => {
const ref: RefObject<OverlayScrollbarsComponentRef> = { current: null }; const ref: RefObject<OverlayScrollbarsComponentRef> = { current: null };
const { container } = render(<OverlayScrollbarsComponent ref={ref} />); const { container } = render(<OverlayScrollbarsComponent ref={ref} />);
@@ -2,8 +2,8 @@ import { useRef } from 'react';
import { describe, test, afterEach, expect } from 'vitest'; import { describe, test, afterEach, expect } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react'; import { render, screen, cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { OverlayScrollbars } from 'overlayscrollbars';
import { useOverlayScrollbars } from '~/overlayscrollbars-react'; import { useOverlayScrollbars } from '~/overlayscrollbars-react';
import type { OverlayScrollbars } from 'overlayscrollbars';
describe('useOverlayScrollbars', () => { describe('useOverlayScrollbars', () => {
afterEach(() => cleanup()); afterEach(() => cleanup());
@@ -13,25 +13,22 @@ describe('useOverlayScrollbars', () => {
const instanceRef = useRef<OverlayScrollbars | null>(null); const instanceRef = useRef<OverlayScrollbars | null>(null);
const [initialize, instance] = useOverlayScrollbars(); const [initialize, instance] = useOverlayScrollbars();
return ( return (
<> <button
<button onClick={(event) => {
onClick={(event) => { initialize(event.target as HTMLElement);
const osInstance = initialize(event.target as HTMLElement); if (instanceRef.current) {
if (instanceRef.current) {
expect(instanceRef.current).toBe(osInstance);
expect(instanceRef.current).toBe(instance());
}
instanceRef.current = osInstance;
expect(instanceRef.current).toBe(instance()); expect(instanceRef.current).toBe(instance());
}} }
> instanceRef.current = instance();
initialize expect(instanceRef.current).toBe(instance());
</button> }}
</> >
initialize
</button>
); );
}; };
render(<Test />); const { unmount } = render(<Test />);
const initializeBtn = screen.getByRole('button'); const initializeBtn = screen.getByRole('button');
userEvent.click(initializeBtn); userEvent.click(initializeBtn);
@@ -41,5 +38,11 @@ describe('useOverlayScrollbars', () => {
userEvent.click(initializeBtn); userEvent.click(initializeBtn);
expect(snapshot).toBe(initializeBtn.innerHTML); expect(snapshot).toBe(initializeBtn.innerHTML);
expect(OverlayScrollbars(initializeBtn)).toBeDefined();
unmount();
expect(OverlayScrollbars(initializeBtn)).toBeUndefined();
}); });
}); });
@@ -8,7 +8,7 @@ import type {
import type { InitializationTarget } from 'overlayscrollbars'; import type { InitializationTarget } from 'overlayscrollbars';
type Defer = [ type Defer = [
request: (callback: () => any, options?: IdleRequestOptions) => void, defer: (callback: () => any, options?: IdleRequestOptions) => void,
clear: () => void clear: () => void
]; ];
@@ -35,8 +35,8 @@ export const useOverlayScrollbarsIdle = (
(...args: Parameters<UseOverlayScrollbarsInitialization>) => void, (...args: Parameters<UseOverlayScrollbarsInitialization>) => void,
UseOverlayScrollbarsInstance UseOverlayScrollbarsInstance
] => { ] => {
const [requestIdle, clearIdle] = useMemo<Defer>(() => createDefer(true), []); const [deferIdle, clearIdle] = useMemo<Defer>(() => createDefer(true), []);
const [requestRAF, clearRAF] = useMemo<Defer>(() => createDefer(), []); const [deferRAF, clearRAF] = useMemo<Defer>(() => createDefer(), []);
const [initialize, instance] = useOverlayScrollbars(params); const [initialize, instance] = useOverlayScrollbars(params);
useEffect(() => { useEffect(() => {
@@ -50,9 +50,9 @@ export const useOverlayScrollbarsIdle = (
return useMemo( return useMemo(
() => [ () => [
(target: InitializationTarget) => { (target: InitializationTarget) => {
requestIdle( deferIdle(
() => { () => {
requestRAF(() => { deferRAF(() => {
initialize(target); initialize(target);
}); });
}, },