diff --git a/.changeset/spotty-pots-exist.md b/.changeset/spotty-pots-exist.md new file mode 100644 index 0000000000..a0b7c66280 --- /dev/null +++ b/.changeset/spotty-pots-exist.md @@ -0,0 +1,6 @@ +--- +'react-select': patch +'@react-select/docs': patch +--- + +Change `class` components to `functional` components diff --git a/docs/ExampleWrapper.tsx b/docs/ExampleWrapper.tsx index c290c5f973..b1e10b108c 100644 --- a/docs/ExampleWrapper.tsx +++ b/docs/ExampleWrapper.tsx @@ -1,7 +1,7 @@ /** @jsx jsx */ import { jsx } from '@emotion/react'; // eslint-disable-line no-unused-vars import { CSSObject } from '@emotion/serialize'; -import { Component } from 'react'; +import { ReactNode, useState } from 'react'; import CodeSandboxer, { GitInfo } from 'react-codesandboxer'; import { CodeBlock } from './markdown/renderer'; import pkg from '../packages/react-select/package.json'; @@ -20,99 +20,69 @@ const gitInfo: GitInfo = { const sourceUrl = `https://github.com/${gitInfo.account}/react-select/tree/${gitInfo.branch}`; interface Props { + children?: ReactNode; readonly label: string; readonly raw: { readonly default: string }; readonly urlPath: string; readonly isEditable?: boolean; } -interface State { - readonly showCode: boolean; -} - -export default class ExampleWrapper extends Component { - state: State = { showCode: false }; - static defaultProps = { isEditable: true }; - - renderCodeSample = () => { - let { raw } = this.props; - let { showCode } = this.state; - - if (!showCode || !raw) { - return null; - } else { - return ; - } - }; - - renderSourceViewOption = () => { - let { raw } = this.props; - let { showCode } = this.state; - - if (!raw) { - return ( - - - - ); - } else { - return ( - this.setState({ showCode: !showCode })} - title="View Source" - > - - - ); - } - }; - - renderCSBButton = () => { - let { isEditable, raw, urlPath } = this.props; +export default ({ + children, + label, + raw, + urlPath, + isEditable = true, +}: Props) => { + const [showCode, setShowCode] = useState(false); - if (isEditable) { - return ( - - {({ isLoading }) => ( - - {isLoading ? : } + return ( +
+ +

{label}

+ + {raw ? ( + setShowCode((prev) => !prev)} + title="View Source" + > + + ) : ( + + + )} - - ); - } else { - return null; - } - }; - - render() { - return ( -
- -

{this.props.label}

- - {this.renderSourceViewOption()} - {this.renderCSBButton()} - -
- {this.renderCodeSample()} - {this.props.children} -
- ); - } -} + {isEditable ? ( + + {({ isLoading }) => ( + + {isLoading ? : } + + )} + + ) : null} +
+
+ {showCode && raw ? ( + + ) : null} + {children} +
+ ); +}; const ExampleHeading = (props: any) => (
{ const toggleMenuIsOpen = () => { setMenuIsOpen((value) => !value); - const { current } = ref; - if (!current) return; - if (menuIsOpen) current.blur(); - else current.focus(); + const selectEl = ref.current; + if (!selectEl) return; + if (menuIsOpen) selectEl.blur(); + else selectEl.focus(); }; return ( diff --git a/packages/react-select/src/NonceProvider.tsx b/packages/react-select/src/NonceProvider.tsx index 1ba548aa88..5e91a85e49 100644 --- a/packages/react-select/src/NonceProvider.tsx +++ b/packages/react-select/src/NonceProvider.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; -import { Component, ReactNode } from 'react'; +import { useMemo } from 'react'; +import { ReactNode } from 'react'; import { CacheProvider } from '@emotion/react'; import createCache from '@emotion/cache'; -import memoizeOne from 'memoize-one'; interface NonceProviderProps { nonce: string; @@ -10,21 +10,10 @@ interface NonceProviderProps { cacheKey: string; } -export default class NonceProvider extends Component { - constructor(props: NonceProviderProps) { - super(props); - this.createEmotionCache = memoizeOne(this.createEmotionCache); - } - createEmotionCache = (nonce: string, key: string) => { - return createCache({ nonce, key }); - }; - render() { - const emotionCache = this.createEmotionCache( - this.props.nonce, - this.props.cacheKey - ); - return ( - {this.props.children} - ); - } -} +export default ({ nonce, children, cacheKey }: NonceProviderProps) => { + const emotionCache = useMemo( + () => createCache({ key: cacheKey, nonce }), + [cacheKey, nonce] + ); + return {children}; +}; diff --git a/packages/react-select/src/animated/transitions.tsx b/packages/react-select/src/animated/transitions.tsx index f1cd0bc5e9..d7742cef68 100644 --- a/packages/react-select/src/animated/transitions.tsx +++ b/packages/react-select/src/animated/transitions.tsx @@ -1,12 +1,6 @@ import * as React from 'react'; -import { - Component, - ComponentType, - createRef, - CSSProperties, - ReactNode, - useRef, -} from 'react'; +import { useEffect, useState } from 'react'; +import { ComponentType, CSSProperties, ReactNode, useRef } from 'react'; import { Transition } from 'react-transition-group'; import { ExitHandler, @@ -72,93 +66,69 @@ interface CollapseProps { in?: boolean; onExited?: ExitHandler; } -interface CollapseState { - width: Width; -} // wrap each MultiValue with a collapse transition; decreases width until // finally removing from DOM -export class Collapse extends Component { - duration = collapseDuration; - rafID?: number | null; - state: CollapseState = { width: 'auto' }; - transition: { [K in TransitionStatus]?: CSSProperties } = { - exiting: { width: 0, transition: `width ${this.duration}ms ease-out` }, - exited: { width: 0 }, - }; - nodeRef = createRef(); +export const Collapse = ({ children, in: _in, onExited }: CollapseProps) => { + const ref = useRef(null); + const [width, setWidth] = useState('auto'); - componentDidMount() { - const { current: ref } = this.nodeRef; + useEffect(() => { + const el = ref.current; + if (!el) return; /* - A check on existence of ref should not be necessary at this point, - but TypeScript demands it. + Here we're invoking requestAnimationFrame with a callback invoking our + call to getBoundingClientRect and setState in order to resolve an edge case + around portalling. Certain portalling solutions briefly remove children from the DOM + before appending them to the target node. This is to avoid us trying to call getBoundingClientrect + while the Select component is in this state. */ - if (ref) { - /* - Here we're invoking requestAnimationFrame with a callback invoking our - call to getBoundingClientRect and setState in order to resolve an edge case - around portalling. Certain portalling solutions briefly remove children from the DOM - before appending them to the target node. This is to avoid us trying to call getBoundingClientrect - while the Select component is in this state. - */ - // cannot use `offsetWidth` because it is rounded - this.rafID = window.requestAnimationFrame(() => { - const { width } = ref.getBoundingClientRect(); - this.setState({ width }); - }); - } - } + // cannot use `offsetWidth` because it is rounded + const rafId = window.requestAnimationFrame(() => + setWidth(el.getBoundingClientRect().width) + ); - componentWillUnmount() { - if (this.rafID) { - window.cancelAnimationFrame(this.rafID); + return () => window.cancelAnimationFrame(rafId); + }, []); + + const getStyleFromStatus = (status: TransitionStatus) => { + switch (status) { + default: + return { width }; + case 'exiting': + return { width: 0, transition: `width ${collapseDuration}ms ease-out` }; + case 'exited': + return { width: 0 }; } - } - - // get base styles - getStyle = (width: Width): CSSProperties => ({ - overflow: 'hidden', - whiteSpace: 'nowrap', - width, - }); - - // get transition styles - getTransition = (state: TransitionStatus) => this.transition[state]; - - render() { - const { children, in: inProp, onExited } = this.props; - const exitedProp = () => { - if (this.nodeRef.current && onExited) { - onExited(this.nodeRef.current); - } - }; - - const { width } = this.state; + }; - return ( - - {(state) => { - const style = { - ...this.getStyle(width), - ...this.getTransition(state), - }; - return ( -
- {children} -
- ); - }} -
- ); - } -} + return ( + { + const el = ref.current; + if (!el) return; + onExited?.(el); + }} + timeout={collapseDuration} + nodeRef={ref} + > + {(status) => ( +
+ {children} +
+ )} +
+ ); +}; diff --git a/packages/react-select/src/components/Menu.tsx b/packages/react-select/src/components/Menu.tsx index 602e13938e..a1a48da148 100644 --- a/packages/react-select/src/components/Menu.tsx +++ b/packages/react-select/src/components/Menu.tsx @@ -1,13 +1,14 @@ /** @jsx jsx */ import { createContext, - Component, + ReactElement, ReactNode, - RefCallback, - ContextType, - useState, + Ref, useCallback, + useContext, + useMemo, useRef, + useState, } from 'react'; import { jsx } from '@emotion/react'; import { createPortal } from 'react-dom'; @@ -55,10 +56,10 @@ interface PlacementArgs { } export function getMenuPlacement({ - maxHeight, + maxHeight: preferredMaxHeight, menuEl, minHeight, - placement, + placement: preferredPlacement, shouldScroll, isFixedPosition, theme, @@ -67,7 +68,7 @@ export function getMenuPlacement({ const scrollParent = getScrollParent(menuEl!); const defaultState: CalculatedMenuPlacementAndHeight = { placement: 'bottom', - maxHeight, + maxHeight: preferredMaxHeight, }; // something went wrong, return default state @@ -99,12 +100,12 @@ export function getMenuPlacement({ const scrollUp = scrollTop + menuTop - marginTop; const scrollDuration = 160; - switch (placement) { + switch (preferredPlacement) { case 'auto': case 'bottom': // 1: the menu will fit, do nothing if (viewSpaceBelow >= menuHeight) { - return { placement: 'bottom', maxHeight }; + return { placement: 'bottom', maxHeight: preferredMaxHeight }; } // 2: the menu will fit, if scrolled @@ -113,7 +114,7 @@ export function getMenuPlacement({ animatedScrollTo(scrollParent, scrollDown, scrollDuration); } - return { placement: 'bottom', maxHeight }; + return { placement: 'bottom', maxHeight: preferredMaxHeight }; } // 3: the menu will fit, if constrained @@ -140,15 +141,15 @@ export function getMenuPlacement({ // 4. Forked beviour when there isn't enough space below // AUTO: flip the menu, render above - if (placement === 'auto' || isFixedPosition) { + if (preferredPlacement === 'auto' || isFixedPosition) { // may need to be constrained after flipping - let constrainedHeight = maxHeight; + let constrainedHeight = preferredMaxHeight; const spaceAbove = isFixedPosition ? viewSpaceAbove : scrollSpaceAbove; if (spaceAbove >= minHeight) { constrainedHeight = Math.min( spaceAbove - marginBottom - spacing.controlHeight, - maxHeight + preferredMaxHeight ); } @@ -156,17 +157,17 @@ export function getMenuPlacement({ } // BOTTOM: allow browser to increase scrollable area and immediately set scroll - if (placement === 'bottom') { + if (preferredPlacement === 'bottom') { if (shouldScroll) { scrollTo(scrollParent, scrollDown); } - return { placement: 'bottom', maxHeight }; + return { placement: 'bottom', maxHeight: preferredMaxHeight }; } break; case 'top': // 1: the menu will fit, do nothing if (viewSpaceAbove >= menuHeight) { - return { placement: 'top', maxHeight }; + return { placement: 'top', maxHeight: preferredMaxHeight }; } // 2: the menu will fit, if scrolled @@ -175,7 +176,7 @@ export function getMenuPlacement({ animatedScrollTo(scrollParent, scrollUp, scrollDuration); } - return { placement: 'top', maxHeight }; + return { placement: 'top', maxHeight: preferredMaxHeight }; } // 3: the menu will fit, if constrained @@ -183,7 +184,7 @@ export function getMenuPlacement({ (!isFixedPosition && scrollSpaceAbove >= minHeight) || (isFixedPosition && viewSpaceAbove >= minHeight) ) { - let constrainedHeight = maxHeight; + let constrainedHeight = preferredMaxHeight; // we want to provide as much of the menu as possible to the user, // so give them whatever is available below rather than the minHeight. @@ -209,9 +210,9 @@ export function getMenuPlacement({ // 4. not enough space, the browser WILL NOT increase scrollable area when // absolutely positioned element rendered above the viewport (only below). // Flip the menu, render below - return { placement: 'bottom', maxHeight }; + return { placement: 'bottom', maxHeight: preferredMaxHeight }; default: - throw new Error(`Invalid placement provided "${placement}".`); + throw new Error(`Invalid placement provided "${preferredPlacement}".`); } return defaultState; @@ -240,7 +241,7 @@ export interface MenuProps< > extends CommonPropsAndClassName, MenuPlacementProps { /** Reference to the internal element, consumed by the MenuPlacer component */ - innerRef: RefCallback; + innerRef: Ref; innerProps: JSX.IntrinsicElements['div']; isLoading: boolean; placement: CoercedMenuPlacement; @@ -254,7 +255,7 @@ interface PlacerProps { } interface ChildrenProps { - ref: RefCallback; + ref: Ref; placerProps: PlacerProps; } @@ -265,7 +266,7 @@ export interface MenuPlacerProps< > extends CommonProps, MenuPlacementProps { /** The children to be rendered. */ - children: (childrenProps: ChildrenProps) => ReactNode; + children: (childrenProps: ChildrenProps) => ReactElement; } function alignToControl(placement: CoercedMenuPlacement) { @@ -294,41 +295,37 @@ export const menuCSS = < zIndex: 1, }); -const PortalPlacementContext = createContext<{ - getPortalPlacement: - | ((menuState: CalculatedMenuPlacementAndHeight) => void) - | null; -}>({ getPortalPlacement: null }); - -interface MenuState { - placement: CoercedMenuPlacement | null; - maxHeight: number; -} +const PortalPlacementContext = + createContext<{ + setPortalPlacement: (placement: CoercedMenuPlacement) => void; + } | null>(null); // NOTE: internal only -export class MenuPlacer< +export const MenuPlacer = < Option, IsMulti extends boolean, Group extends GroupBase