diff --git a/.size-limit.json b/.size-limit.json index 27fabe8..053aff3 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -7,6 +7,10 @@ "path": "packages/local-storage/dist/index.js", "limit": "290 B" }, + { + "path": "packages/react/dist/index.js", + "limit": "4.25 KB" + }, { "path": "packages/session-storage/dist/index.js", "limit": "290 B" diff --git a/README.md b/README.md index b7962c5..74d36bd 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Common utils used by Rambler team - [@rambler-tech/cookie-storage](packages/cookie-storage) - [@rambler-tech/lhci-report](packages/lhci-report) - [@rambler-tech/local-storage](packages/local-storage) +- [@rambler-tech/react](packages/react) - [@rambler-tech/session-storage](packages/session-storage) - [@rambler-tech/url](packages/url) diff --git a/packages/react/README.md b/packages/react/README.md new file mode 100644 index 0000000..ef91ca7 --- /dev/null +++ b/packages/react/README.md @@ -0,0 +1,15 @@ +# React + +React hooks and utils + +## Install + +``` +npm install -D @rambler-tech/react +``` + +or + +``` +yarn add -D @rambler-tech/react +``` diff --git a/packages/react/error-boundary.tsx b/packages/react/error-boundary.tsx new file mode 100644 index 0000000..824cd3c --- /dev/null +++ b/packages/react/error-boundary.tsx @@ -0,0 +1,46 @@ +import {ErrorInfo, PureComponent} from 'react' + +/** Error boundary props */ +export interface ErrorBoundaryProps { + /** Error callback */ + onError: (error: Error, errorInfo: Record) => void + /** Fallback that shows on error */ + fallback: JSX.Element + /** Children to follow errors */ + children: JSX.Element +} + +interface ErrorBoundaryState { + isError: boolean +} + +/** Error boundary */ +export class ErrorBoundary extends PureComponent< + ErrorBoundaryProps, + ErrorBoundaryState +> { + state = { + isError: false + } + + static getDerivedStateFromError(): ErrorBoundaryState { + return {isError: true} + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + const {onError} = this.props + + onError(error, errorInfo) + } + + render(): JSX.Element { + const {isError} = this.state + const {fallback, children} = this.props + + if (isError) { + return fallback + } + + return children + } +} diff --git a/packages/react/index.ts b/packages/react/index.ts new file mode 100644 index 0000000..30894c6 --- /dev/null +++ b/packages/react/index.ts @@ -0,0 +1,9 @@ +/* eslint-disable import/no-unused-modules */ +export {ErrorBoundary, type ErrorBoundaryProps} from './error-boundary' +export {isSSR} from './ssr' +export {useClickOutside} from './use-click-outside' +export {useCountdownTimer, type Timer} from './use-countdown-timer' +export {useInterval} from './use-interval' +export {useScrollPosition} from './use-scroll-position' +export {useTimeout} from './use-timeout' +export {useViewport, type Viewport} from './use-viewport' diff --git a/packages/react/package.json b/packages/react/package.json new file mode 100644 index 0000000..cc61d74 --- /dev/null +++ b/packages/react/package.json @@ -0,0 +1,21 @@ +{ + "name": "@rambler-tech/react", + "version": "0.0.0", + "main": "dist", + "module": "dist", + "types": "dist/index.d.ts", + "license": "MIT", + "sideEffects": false, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "raf-throttle": "^2.0.5" + }, + "devDependencies": { + "@types/react": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } +} diff --git a/packages/react/ssr.ts b/packages/react/ssr.ts new file mode 100644 index 0000000..e1809ef --- /dev/null +++ b/packages/react/ssr.ts @@ -0,0 +1,2 @@ +/** Check is server-side render */ +export const isSSR = typeof window === 'undefined' diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json new file mode 120000 index 0000000..238bf1b --- /dev/null +++ b/packages/react/tsconfig.json @@ -0,0 +1 @@ +../../tsconfig.package.json \ No newline at end of file diff --git a/packages/react/typedoc.json b/packages/react/typedoc.json new file mode 120000 index 0000000..213b456 --- /dev/null +++ b/packages/react/typedoc.json @@ -0,0 +1 @@ +../../typedoc.package.json \ No newline at end of file diff --git a/packages/react/use-click-outside.ts b/packages/react/use-click-outside.ts new file mode 100644 index 0000000..3d4f8e6 --- /dev/null +++ b/packages/react/use-click-outside.ts @@ -0,0 +1,37 @@ +import {useEffect, RefObject} from 'react' + +/** Listen click outside */ +export function useClickOutside( + ref: RefObject, + callback: (...args: any[]) => any +) { + useEffect(() => { + let startedInside = false + let startedWhenMounted = false + + const listener = (event: MouseEvent): void => { + // Игнорировать событие если `mousedown` или `touchstart` внутри элемента + if (startedInside || !startedWhenMounted) return + // Игнорировать если клик по элементу ref или дочерним + if (!ref.current || ref.current.contains(event.target as HTMLElement)) + return + + callback(event) + } + + const validateEventStart = (event: any): void => { + startedWhenMounted = !!ref.current + startedInside = !!(ref.current && ref.current.contains(event.target)) + } + + document.addEventListener('mousedown', validateEventStart) + document.addEventListener('touchstart', validateEventStart) + document.addEventListener('click', listener) + + return () => { + document.removeEventListener('mousedown', validateEventStart) + document.removeEventListener('touchstart', validateEventStart) + document.removeEventListener('click', listener) + } + }, [ref, callback]) +} diff --git a/packages/react/use-countdown-timer.ts b/packages/react/use-countdown-timer.ts new file mode 100644 index 0000000..551f92e --- /dev/null +++ b/packages/react/use-countdown-timer.ts @@ -0,0 +1,54 @@ +import {useState, useRef, useEffect} from 'react' + +/** Countdown timer instance */ +export interface Timer { + /** Remaining time */ + remainingTime: number | null + /** Start timer */ + startTimer(time: number): void + /** Stop timer */ + stopTimer(): void +} + +/** Countdown timer */ +export function useCountdownTimer(): Timer { + const [remainingTime, setRemainingTime] = useState(null) + const timerId = useRef() + + const clear = (): void => { + if (timerId.current) { + window.clearInterval(timerId.current) + } + } + + useEffect(() => { + return () => clear() + }, []) + + useEffect(() => { + if (remainingTime === 0) { + clear() + } + }, [remainingTime]) + + const stopTimer = (): void => { + clear() + setRemainingTime(0) + } + + const tick = (): void => { + setRemainingTime((prevTime) => prevTime && prevTime - 1) + } + + const startTimer = (time: number): void => { + clear() + timerId.current = window.setInterval(tick, 1000) + setRemainingTime(time) + } + + return { + remainingTime, + stopTimer, + startTimer + } +} diff --git a/packages/react/use-interval.ts b/packages/react/use-interval.ts new file mode 100644 index 0000000..22c7530 --- /dev/null +++ b/packages/react/use-interval.ts @@ -0,0 +1,17 @@ +import {useEffect, useRef} from 'react' + +/** Start interval */ +export function useInterval(callback: () => void, delay: number) { + const savedCallback = useRef<() => void>() + + useEffect(() => { + savedCallback.current = callback + }, [callback]) + + useEffect(() => { + const tick = (): void => savedCallback.current?.() + const id = window.setInterval(tick, delay) + + return () => window.clearInterval(id) + }, [delay]) +} diff --git a/packages/react/use-scroll-position.ts b/packages/react/use-scroll-position.ts new file mode 100644 index 0000000..701b6ed --- /dev/null +++ b/packages/react/use-scroll-position.ts @@ -0,0 +1,33 @@ +import {useRef, useEffect, DependencyList} from 'react' +import throttle from 'raf-throttle' +import {isSSR} from './ssr' + +function getScrollPosition() { + return isSSR ? 0 : window.pageYOffset +} + +/** Listen scroll position */ +export function useScrollPosition( + callback: (current: number, prev: number) => void, + deps: DependencyList = [] +) { + const scrollPosition = useRef(getScrollPosition()) + + useEffect(() => { + if (isSSR) return + + const handleScroll = throttle((): void => { + const currentScrollPosition = getScrollPosition() + + callback(currentScrollPosition, scrollPosition.current) + + scrollPosition.current = currentScrollPosition + }) + + window.addEventListener('scroll', handleScroll, {passive: true}) + + return () => { + window.removeEventListener('scroll', handleScroll) + } + }, deps) +} diff --git a/packages/react/use-timeout.ts b/packages/react/use-timeout.ts new file mode 100644 index 0000000..ad8e3b0 --- /dev/null +++ b/packages/react/use-timeout.ts @@ -0,0 +1,17 @@ +import {useEffect, useRef} from 'react' + +/** Start timeout */ +export function useTimeout(callback: () => void, delay: number) { + const savedCallback = useRef<() => void>() + + useEffect(() => { + savedCallback.current = callback + }, [callback]) + + useEffect(() => { + const tick = (): void => savedCallback.current?.() + const timerId = window.setTimeout(tick, delay) + + return () => window.clearTimeout(timerId) + }, [delay]) +} diff --git a/packages/react/use-viewport.ts b/packages/react/use-viewport.ts new file mode 100644 index 0000000..bd63c78 --- /dev/null +++ b/packages/react/use-viewport.ts @@ -0,0 +1,51 @@ +import {useState, useEffect} from 'react' +import throttle from 'raf-throttle' +import {isSSR} from './ssr' + +/** Viewport info */ +export interface Viewport { + width: number + isMobile: boolean + isTablet: boolean + isDesktop: boolean +} + +const BREAKPOINT = 768 + +export function useViewport(breakpoints?: [number, number]): Viewport +export function useViewport(breakpoints?: [number]): Omit + +/** Listen viewport change */ +export function useViewport( + breakpoints: [number, number] | [number] = [BREAKPOINT] +): Viewport | Omit { + const [mobile, desktop] = breakpoints + const [width, setWidth] = useState(isSSR ? 0 : window.innerWidth) + + useEffect(() => { + const updateWidth = throttle(() => { + setWidth(window.innerWidth) + }) + + window.addEventListener('resize', updateWidth) + + return () => { + window.removeEventListener('resize', updateWidth) + } + }, []) + + if (typeof desktop === 'number') { + return { + width, + isMobile: width < mobile, + isTablet: width >= mobile && width < desktop, + isDesktop: width >= desktop + } + } + + return { + width, + isMobile: width < mobile, + isDesktop: width >= mobile + } +} diff --git a/yarn.lock b/yarn.lock index dc335a9..b1c23e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1775,6 +1775,19 @@ resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301" integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw== +"@types/prop-types@*": + version "15.7.12" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.12.tgz#12bb1e2be27293c1406acb6af1c3f3a1481d98c6" + integrity sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q== + +"@types/react@^18.2.0": + version "18.3.3" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.3.tgz#9679020895318b0915d7a3ab004d92d33375c45f" + integrity sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw== + dependencies: + "@types/prop-types" "*" + csstype "^3.0.2" + "@types/semver@^7.3.12": version "7.5.7" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.7.tgz#326f5fdda70d13580777bcaa1bc6fa772a5aef0e" @@ -3131,6 +3144,11 @@ cssstyle@^2.3.0: dependencies: cssom "~0.3.6" +csstype@^3.0.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" + integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== + damerau-levenshtein@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7" @@ -7368,6 +7386,11 @@ quick-lru@^4.0.1: resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f" integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g== +raf-throttle@^2.0.5: + version "2.0.6" + resolved "https://registry.yarnpkg.com/raf-throttle/-/raf-throttle-2.0.6.tgz#5f1c273d1630fff421df31fe01551faa04b86700" + integrity sha512-C7W6hy78A+vMmk5a/B6C5szjBHrUzWJkVyakjKCK59Uy2CcA7KhO1JUvvH32IXYFIcyJ3FMKP3ZzCc2/71I6Vg== + react-is@^16.13.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"