diff --git a/packages/overlayscrollbars-react/package.json b/packages/overlayscrollbars-react/package.json index a8e6ab1..163ec80 100644 --- a/packages/overlayscrollbars-react/package.json +++ b/packages/overlayscrollbars-react/package.json @@ -2,6 +2,26 @@ "name": "overlayscrollbars-react", "private": true, "version": "0.4.0", + "description": "OverlayScrollbars for React.", + "author": "Rene Haas | KingSora", + "license": "MIT", + "homepage": "https://kingsora.github.io/OverlayScrollbars", + "bugs": "https://github.com/KingSora/OverlayScrollbars/issues", + "repository": { + "type": "git", + "url": "https://github.com/KingSora/OverlayScrollbars.git", + "directory": "packages/overlayscrollbars-react" + }, + "keywords": [ + "overlayscrollbars", + "react", + "component", + "hooks", + "styleable", + "scrollbar", + "scrollbars", + "scroll" + ], "main": "./dist/overlayscrollbars-react.umd.js", "module": "./dist/overlayscrollbars-react.es.js", "types": "./dist/overlayscrollbars-react.d.ts", diff --git a/packages/overlayscrollbars-react/src/OverlayScrollbarsComponent.tsx b/packages/overlayscrollbars-react/src/OverlayScrollbarsComponent.tsx index e9a0f23..9a58529 100644 --- a/packages/overlayscrollbars-react/src/OverlayScrollbarsComponent.tsx +++ b/packages/overlayscrollbars-react/src/OverlayScrollbarsComponent.tsx @@ -1,17 +1,23 @@ import { forwardRef, useEffect, useRef, useImperativeHandle } from 'react'; -import { OverlayScrollbars } from 'overlayscrollbars'; +import type { OverlayScrollbars } from 'overlayscrollbars'; import type { PartialOptions, EventListeners } from 'overlayscrollbars'; import type { ComponentPropsWithoutRef, ElementRef, ForwardedRef } from 'react'; +import { useOverlayScrollbars } from './useOverlayScrollbars'; export type OverlayScrollbarsComponentProps = ComponentPropsWithoutRef & { + /** Tag of the root element. */ element?: T; + /** OverlayScrollbars options. */ options?: PartialOptions; + /** OverlayScrollbars events. */ events?: EventListeners; }; export interface OverlayScrollbarsComponentRef { + /** Returns the OverlayScrollbars instance or null if not initialized. */ instance(): OverlayScrollbars | null; + /** Returns the target element. */ target(): ElementRef | null; } @@ -22,50 +28,31 @@ const OverlayScrollbarsComponent = ( const { element = 'div', options, events, children, ...other } = props; const Tag = element; + const [initialize, instance] = useOverlayScrollbars(options, events); const osTargetRef = useRef>(null); const osChildrenRef = useRef(null); - const osInstanceRef = useRef(null); useEffect(() => { const { current: targetElm } = osTargetRef; const { current: childrenElm } = osChildrenRef; if (targetElm && childrenElm) { - const instance = OverlayScrollbars( - { - target: targetElm as any, - elements: { - viewport: childrenElm, - content: childrenElm, - }, + const osInstance = initialize({ + target: targetElm as any, + elements: { + viewport: childrenElm, + content: childrenElm, }, - options || {}, - events - ); - osInstanceRef.current = instance; + }); - return () => instance.destroy(); + return () => osInstance.destroy(); } }, []); - useEffect(() => { - const { current: instance } = osInstanceRef; - if (OverlayScrollbars.valid(instance) && options) { - instance.options(options, true); - } - }, [options]); - - useEffect(() => { - const { current: instance } = osInstanceRef; - if (OverlayScrollbars.valid(instance) && events) { - return instance.on(events); - } - }, [events]); - useImperativeHandle( ref, () => { return { - instance: () => osInstanceRef.current, + instance, target: () => osTargetRef.current, }; }, diff --git a/packages/overlayscrollbars-react/src/overlayscrollbars-react.ts b/packages/overlayscrollbars-react/src/overlayscrollbars-react.ts index 844b3f0..ce1db92 100644 --- a/packages/overlayscrollbars-react/src/overlayscrollbars-react.ts +++ b/packages/overlayscrollbars-react/src/overlayscrollbars-react.ts @@ -1 +1,2 @@ export * from './OverlayScrollbarsComponent'; +export * from './useOverlayScrollbars'; diff --git a/packages/overlayscrollbars-react/src/useOverlayScrollbars.ts b/packages/overlayscrollbars-react/src/useOverlayScrollbars.ts new file mode 100644 index 0000000..52c599f --- /dev/null +++ b/packages/overlayscrollbars-react/src/useOverlayScrollbars.ts @@ -0,0 +1,69 @@ +import { useEffect, useCallback, useRef } from 'react'; +import { OverlayScrollbars } from 'overlayscrollbars'; +import type { PartialOptions, InitializationTarget, EventListeners } from 'overlayscrollbars'; + +export type UseOverlayScrollbarsInitialization = ( + target: InitializationTarget +) => OverlayScrollbars; + +export type UseOverlayScrollbarsInstance = () => OverlayScrollbars | null; + +/** + * Hook for advanced usage of OverlayScrollbars. (When the OverlayScrollbarsComponent is not enough) + * @param options OverlayScrollbars options. + * @param events OverlayScrollbars events. + * @returns A tuple with two values: + * The first value is the initialization function. + * The second value is an function which returns the current OverlayScrollbars instance or null if not initialized. + */ +export const useOverlayScrollbars = ( + options?: PartialOptions, + events?: EventListeners +): [UseOverlayScrollbarsInitialization, UseOverlayScrollbarsInstance] => { + const osInstanceRef = useRef(null); + const optionsRef = useRef(); + const eventsRef = useRef(); + const offInitialEventsRef = useRef<(() => void) | void>(); + + useEffect(() => { + const { current: instance } = osInstanceRef; + if (OverlayScrollbars.valid(instance) && options) { + instance.options(options, true); + } + }, [options]); + + useEffect(() => { + const { current: instance } = osInstanceRef; + const { current: offInitialEvents } = offInitialEventsRef; + if (OverlayScrollbars.valid(instance) && events) { + offInitialEvents && (offInitialEventsRef.current = offInitialEvents()); // once called assign it to undefined so its not called again + return instance.on(events); + } + }, [events]); + + optionsRef.current = options; + eventsRef.current = events; + + return [ + useCallback((target: InitializationTarget): OverlayScrollbars => { + // if already initialized return the current instance + const presentInstance = osInstanceRef.current; + if (OverlayScrollbars.valid(presentInstance)) { + return presentInstance; + } + + const currOptions = optionsRef.current || {}; + const currEvents = eventsRef.current || {}; + const osInstance = (osInstanceRef.current = OverlayScrollbars( + target, + currOptions, + currEvents + )); + + offInitialEventsRef.current = osInstance.on(currEvents); + + return osInstance; + }, []), + useCallback(() => osInstanceRef.current, []), + ]; +}; diff --git a/packages/overlayscrollbars-react/test/OverlayScrollbarsComponent.test.tsx b/packages/overlayscrollbars-react/test/OverlayScrollbarsComponent.test.tsx index 6aa2da2..53cb438 100644 --- a/packages/overlayscrollbars-react/test/OverlayScrollbarsComponent.test.tsx +++ b/packages/overlayscrollbars-react/test/OverlayScrollbarsComponent.test.tsx @@ -122,4 +122,50 @@ describe('OverlayScrollbarsComponent', () => { expect(onUpdatedInitial).toHaveBeenCalledTimes(2); expect(onUpdated).toHaveBeenCalledTimes(2); }); + + test('events', () => { + const ref: RefObject = { current: null }; + const onUpdatedInitial = vitest.fn(); + const onUpdated = vitest.fn(); + const { rerender } = render( + + ); + + expect(onUpdatedInitial).toHaveBeenCalledTimes(1); + + rerender(); + + expect(onUpdated).not.toHaveBeenCalled(); + + ref.current!.instance()!.update(true); + expect(onUpdatedInitial).toHaveBeenCalledTimes(1); + expect(onUpdated).toHaveBeenCalledTimes(1); + + rerender(); + + ref.current!.instance()!.update(true); + expect(onUpdatedInitial).toHaveBeenCalledTimes(2); + expect(onUpdated).toHaveBeenCalledTimes(1); + + rerender(); + + ref.current!.instance()!.update(true); + expect(onUpdatedInitial).toHaveBeenCalledTimes(2); + expect(onUpdated).toHaveBeenCalledTimes(2); + + rerender( + + ); + + ref.current!.instance()!.update(true); + expect(onUpdatedInitial).toHaveBeenCalledTimes(3); + expect(onUpdated).toHaveBeenCalledTimes(3); + + // unregister works with `[]`, `null` or `undefined` + rerender(); + + ref.current!.instance()!.update(true); + expect(onUpdatedInitial).toHaveBeenCalledTimes(3); + expect(onUpdated).toHaveBeenCalledTimes(3); + }); }); diff --git a/packages/overlayscrollbars-react/test/useOverlayScrollbars.test.tsx b/packages/overlayscrollbars-react/test/useOverlayScrollbars.test.tsx new file mode 100644 index 0000000..e178a1e --- /dev/null +++ b/packages/overlayscrollbars-react/test/useOverlayScrollbars.test.tsx @@ -0,0 +1,43 @@ +import { useRef } from 'react'; +import { describe, test, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useOverlayScrollbars } from '~/overlayscrollbars-react'; +import type { OverlayScrollbars } from 'overlayscrollbars'; + +describe('useOverlayScrollbars', () => { + test('re-initialization', () => { + const Test = () => { + const instanceRef = useRef(null); + const [initialize, instance] = useOverlayScrollbars(); + return ( + <> + + + ); + }; + + render(); + + const initializeBtn = screen.getByRole('button'); + userEvent.click(initializeBtn); + // taking snapshot here wouldn't be equal because of "tabindex" attribute of the viewport element + userEvent.click(initializeBtn); + const snapshot = initializeBtn.innerHTML; + userEvent.click(initializeBtn); + + expect(snapshot).toBe(initializeBtn.innerHTML); + }); +}); diff --git a/packages/overlayscrollbars/src/overlayscrollbars.ts b/packages/overlayscrollbars/src/overlayscrollbars.ts index efea5f9..9e4233f 100644 --- a/packages/overlayscrollbars/src/overlayscrollbars.ts +++ b/packages/overlayscrollbars/src/overlayscrollbars.ts @@ -39,7 +39,7 @@ export interface OverlayScrollbarsStatic { */ (target: InitializationTarget): OverlayScrollbars | undefined; /** - * Initialized a new OverlayScrollbars instance to the given target + * Initializes a new OverlayScrollbars instance to the given target * or returns the current OverlayScrollbars instance if the target already has an instance. * @param target The target. * @param options The options. (Can be just an empty object) @@ -57,7 +57,7 @@ export interface OverlayScrollbarsStatic { */ plugin(plugin: Plugin | Plugin[]): void; /** - * Checkts whether the passed value is a valid overlayscrollbars instance. + * Checks whether the passed value is a valid and not destroyed overlayscrollbars instance. * @param osInstance The value which shall be checked. */ valid(osInstance: any): osInstance is OverlayScrollbars;