diff --git a/package.json b/package.json index c716dbb..25c8b38 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@babel/preset-env": "^7.7.1", "@babel/preset-react": "^7.7.0", "@rollup/plugin-replace": "^2.2.1", + "@types/react": "^18.3.12", "bundlesize": "^0.18.0", "escape-html": "^1.0.3", "eslint": "^6.6.0", @@ -76,6 +77,6 @@ "rollup-plugin-terser": "^5.1.2" }, "dependencies": { - "prop-types": "^15.7.2" + "typescript": "^5.7.2" } } diff --git a/rollup.config.js b/rollup.config.js index d4d38c0..7872f6e 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -6,7 +6,7 @@ import { terser } from 'rollup-plugin-terser'; import pkg from './package.json'; const baseConfig = { - input: 'src/index.js', + input: 'src/index.tsx', external: ['react', 'react-dom', 'prop-types'], output: [ { file: pkg.main, format: 'cjs' }, diff --git a/scripts/extract-docs.js b/scripts/extract-docs.js index e9056f3..e4f3421 100755 --- a/scripts/extract-docs.js +++ b/scripts/extract-docs.js @@ -3,7 +3,7 @@ const fs = require('fs'); const reactDocs = require('react-docgen'); const displayNameHandler = require('react-docgen-displayname-handler').default; -const files = ['src/index.js']; +const files = ['src/index.tsx']; const resolver = reactDocs.resolver.findAllComponentDefinitions; const handlers = reactDocs.defaultHandlers.concat([displayNameHandler]); diff --git a/src/index.js b/src/index.tsx similarity index 76% rename from src/index.js rename to src/index.tsx index e4a62f6..ff94713 100644 --- a/src/index.js +++ b/src/index.tsx @@ -1,25 +1,58 @@ import React, { + CSSProperties, + MutableRefObject, + ReactElement, + ReactNode, useContext, useEffect, useMemo, useReducer, useRef } from 'react'; -import PropTypes from 'prop-types'; -const Context = React.createContext(); +enum Direction { + up = 'up', + left = 'left', + right = 'right', + down = 'down' +} + +interface CanScroll { + [Direction.up]: boolean; + [Direction.left]: boolean; + [Direction.right]: boolean; + [Direction.down]: boolean; +} + +interface Dispatch { + type: string; + direction: keyof typeof Direction; + canScroll: boolean; +} + +interface OverflowContext { + tolerance?: number | string; + refs: { viewport: MutableRefObject }; + canScroll?: CanScroll; + state: { + canScroll: CanScroll; + }; + dispatch?: ({ type, direction, canScroll }: Dispatch) => void; +} + +const Context = React.createContext({}); export function useOverflow() { return useContext(Context); } -const containerStyle = { +const containerStyle: CSSProperties = { display: 'flex', flexDirection: 'column', position: 'relative' }; -const viewportStyle = { +const viewportStyle: CSSProperties = { position: 'relative', flexBasis: '100%', flexShrink: 1, @@ -27,14 +60,14 @@ const viewportStyle = { overflow: 'auto' }; -const contentStyle = { +const contentStyle: CSSProperties = { display: 'inline-block', position: 'relative', minWidth: '100%', boxSizing: 'border-box' }; -function reducer(state, action) { +function reducer(state: { canScroll: CanScroll }, action: Dispatch) { switch (action.type) { case 'CHANGE': { const currentValue = state.canScroll[action.direction]; @@ -104,10 +137,10 @@ export default function Overflow({ style: styleProp, tolerance = 0, ...rest -}) { +}: Overflow) { const [state, dispatch] = useReducer(reducer, null, getInitialState); const hidden = rest.hidden; - const viewportRef = useRef(); + const viewportRef = useRef(null); const style = useMemo( () => ({ @@ -151,18 +184,21 @@ export default function Overflow({ ); } -Overflow.propTypes = { +interface Overflow { /** * Elements to render inside the outer container. This should include an * `` element at a minimum, but should also include your * scroll indicators if you’d like to overlay them on the scrollable viewport. */ - children: PropTypes.node, + children: ReactNode; /** * Callback that receives the latest overflow state and an object of refs, if * you’d like to react to overflow in a custom way. */ - onStateChange: PropTypes.func, + onStateChange: ( + state: OverflowContext['state'], + refs: OverflowContext['refs'] + ) => void; /** * Distance (number of pixels or CSS length unit like `1em`) to the edge of * the content at which to consider the viewport fully scrolled. For example, @@ -170,8 +206,10 @@ Overflow.propTypes = { * long as it’s within 10 pixels of the border. You can use this when your * content has padding and scrolling close to the edge should be good enough. */ - tolerance: PropTypes.oneOfType([PropTypes.number, PropTypes.string]) -}; + tolerance: number | string; + style: CSSProperties; + hidden: boolean; +} // For Firefox, update on a threshold of 0 in addition to any intersection at // all (represented by a tiny tiny threshold). @@ -188,20 +226,29 @@ const threshold = [0, 1e-12]; * own element inside `` instead – otherwise you risk * interfering with the styles this component needs to function. */ -function OverflowContent({ children, style: styleProp, ...rest }) { +function OverflowContent({ + children, + style: styleProp, + ...rest +}: OverflowContent) { const { dispatch, tolerance, refs } = useOverflow(); const { viewport: viewportRef } = refs; - const contentRef = useRef(); - const toleranceRef = useRef(); + const contentRef = useRef(null); + const toleranceRef = useRef(null); const watchRef = tolerance ? toleranceRef : contentRef; - const observersRef = useRef(); + const observersRef = useRef<{ + [Direction.up]: IntersectionObserver; + [Direction.left]: IntersectionObserver; + [Direction.down]: IntersectionObserver; + [Direction.right]: IntersectionObserver; + } | null>(null); useEffect(() => { let ignore = false; const root = viewportRef.current; - const createObserver = (direction, rootMargin) => { + const createObserver = (direction: Direction, rootMargin?: string) => { return new IntersectionObserver( ([entry]) => { if (ignore) { @@ -219,7 +266,7 @@ function OverflowContent({ children, style: styleProp, ...rest }) { // case. entry.intersectionRatio !== 0 && entry.isIntersecting; - dispatch({ type: 'CHANGE', direction, canScroll }); + dispatch?.({ type: 'CHANGE', direction, canScroll }); }, { root, @@ -230,10 +277,10 @@ function OverflowContent({ children, style: styleProp, ...rest }) { }; const observers = { - up: createObserver('up', '100% 0px -100% 0px'), - left: createObserver('left', '0px -100% 0px 100%'), - right: createObserver('right', '0px 100% 0px -100%'), - down: createObserver('down', '-100% 0px 100% 0px') + up: createObserver(Direction.up, '100% 0px -100% 0px'), + left: createObserver(Direction.left, '0px -100% 0px 100%'), + right: createObserver(Direction.right, '0px 100% 0px -100%'), + down: createObserver(Direction.down, '-100% 0px 100% 0px') }; observersRef.current = observers; @@ -251,16 +298,20 @@ function OverflowContent({ children, style: styleProp, ...rest }) { const observers = observersRef.current; const watchNode = watchRef.current; - observers.up.observe(watchNode); - observers.left.observe(watchNode); - observers.right.observe(watchNode); - observers.down.observe(watchNode); + if (watchNode) { + observers?.up.observe(watchNode); + observers?.left.observe(watchNode); + observers?.right.observe(watchNode); + observers?.down.observe(watchNode); + } return () => { - observers.up.unobserve(watchNode); - observers.left.unobserve(watchNode); - observers.right.unobserve(watchNode); - observers.down.unobserve(watchNode); + if (watchNode) { + observers?.up.unobserve(watchNode); + observers?.left.unobserve(watchNode); + observers?.right.unobserve(watchNode); + observers?.down.unobserve(watchNode); + } }; }, [watchRef]); @@ -304,12 +355,13 @@ function OverflowContent({ children, style: styleProp, ...rest }) { OverflowContent.displayName = 'Overflow.Content'; -OverflowContent.propTypes = { +interface OverflowContent { /** * Content to render inside the scrollable viewport. */ - children: PropTypes.node -}; + children: ReactNode; + style: CSSProperties; +} /** * A helper component for rendering your custom indicator when the viewport is @@ -352,7 +404,7 @@ OverflowContent.propTypes = { * * ``` */ -function OverflowIndicator({ children, direction }) { +function OverflowIndicator({ children, direction }: OverflowIndicator) { const { state, refs } = useOverflow(); const { canScroll } = state; const isActive = direction @@ -372,20 +424,25 @@ function OverflowIndicator({ children, direction }) { OverflowIndicator.displayName = 'Overflow.Indicator'; -OverflowIndicator.propTypes = { +interface OverflowIndicator { /** * Indicator to render when scrolling is allowed in the requested direction. * If given a function, it will be passed the overflow state and an object * containing the `viewport` ref. You can use this `refs` parameter to render * an indicator that is also a button that scrolls the viewport (for example). */ - children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]), + children: + | ReactElement + | (( + stateArg: boolean | CanScroll, + refs: OverflowContext['refs'] + ) => ReactElement); /** * The scrollabe direction to watch for. If not supplied, the indicator will * be active when scrolling is allowed in any direction. */ - direction: PropTypes.oneOf(['up', 'down', 'left', 'right']) -}; + direction: keyof typeof Direction; +} Overflow.Indicator = OverflowIndicator; Overflow.Content = OverflowContent; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1391cda --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "strictNullChecks": true, + "forceConsistentCasingInFileNames": true, + "module": "es2020", + "moduleResolution": "node", + "resolveJsonModule": true, + "noEmit": true, + "experimentalDecorators": true, + "jsx": "preserve", + "baseUrl": "./src", + "useUnknownInCatchVariables": false, + "composite": true, + "incremental": true + }, + "include": [ + "./src/**/*" + ], + "exclude": [ + "node_modules" + ] +}