From a6698d48474595f2ee05a3f817ecbdb322aa7807 Mon Sep 17 00:00:00 2001 From: Nigel Lima Date: Wed, 24 Jan 2024 04:17:01 -0300 Subject: [PATCH] fix(button): Fixed Typescript error when using Next.js Link in the button `as` prop (#1244) * fix(button): correctly infer type from `as` prop `as` prop was throwing TS errors when using Next Link component. Now this correctly infers the type from any component passed to the `as` prop fix #1002 fix #1107 * fix(dropdown-item): applied the generic types to DropdownItem and also wraps it with a forwardRef * docs(button): removed now-unnecessary @ts-expect-error comments * fix(button): replaced use of old genericForwardRef --- examples/button/button.polymorph.tsx | 1 - examples/dropdown/dropdown.customItem.tsx | 1 - src/components/Button/Button.tsx | 193 +++++++++++----------- src/components/Button/ButtonBase.tsx | 21 +-- src/components/Dropdown/Dropdown.spec.tsx | 2 +- src/components/Dropdown/Dropdown.tsx | 9 +- src/components/Dropdown/DropdownItem.tsx | 92 ++++++----- src/helpers/generic-as-prop.ts | 23 +++ src/helpers/generic-forward-ref.ts | 13 -- 9 files changed, 184 insertions(+), 171 deletions(-) create mode 100644 src/helpers/generic-as-prop.ts delete mode 100644 src/helpers/generic-forward-ref.ts diff --git a/examples/button/button.polymorph.tsx b/examples/button/button.polymorph.tsx index 3609ffff8..ea99ef348 100644 --- a/examples/button/button.polymorph.tsx +++ b/examples/button/button.polymorph.tsx @@ -46,7 +46,6 @@ function Component() { - {/* @ts-expect-error TODO: fix `as` inference */} diff --git a/examples/dropdown/dropdown.customItem.tsx b/examples/dropdown/dropdown.customItem.tsx index 96509097b..d6fbc7aa0 100644 --- a/examples/dropdown/dropdown.customItem.tsx +++ b/examples/dropdown/dropdown.customItem.tsx @@ -41,7 +41,6 @@ function Component() { function Component() { return ( - {/* @ts-expect-error TODO: fix `as` inference */} Home diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 1df740f29..53c672d12 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -1,7 +1,6 @@ -import type { ComponentPropsWithoutRef, ElementType, ForwardedRef } from 'react'; -import { type ReactNode } from 'react'; +import type { ElementType } from 'react'; +import { forwardRef, type ReactNode } from 'react'; import { twMerge } from 'tailwind-merge'; -import genericForwardRef from '../../helpers/generic-forward-ref'; import { mergeDeep } from '../../helpers/merge-deep'; import { getTheme } from '../../theme-store'; import type { DeepPartial } from '../../types'; @@ -16,6 +15,7 @@ import { Spinner } from '../Spinner'; import { ButtonBase, type ButtonBaseProps } from './ButtonBase'; import type { PositionInButtonGroup } from './ButtonGroup'; import { ButtonGroup } from './ButtonGroup'; +import type { PolymorphicComponentPropWithRef, PolymorphicRef } from '../../helpers/generic-as-prop'; export interface FlowbiteButtonTheme { base: string; @@ -67,105 +67,110 @@ export interface ButtonSizes extends Pick = { - as?: T | null; - href?: string; - color?: keyof FlowbiteColors; - fullSized?: boolean; - gradientDuoTone?: keyof ButtonGradientDuoToneColors; - gradientMonochrome?: keyof ButtonGradientColors; - target?: string; - isProcessing?: boolean; - processingLabel?: string; - processingSpinner?: ReactNode; - label?: ReactNode; - outline?: boolean; - pill?: boolean; - positionInGroup?: keyof PositionInButtonGroup; - size?: keyof ButtonSizes; - theme?: DeepPartial; -} & ComponentPropsWithoutRef; - -const ButtonComponentFn = ( +export type ButtonProps = PolymorphicComponentPropWithRef< + T, { - children, - className, - color = 'info', - disabled, - fullSized, - isProcessing = false, - processingLabel = 'Loading...', - processingSpinner, - gradientDuoTone, - gradientMonochrome, - label, - outline = false, - pill = false, - positionInGroup = 'none', - size = 'md', - theme: customTheme = {}, - ...props - }: ButtonProps, - ref: ForwardedRef, -) => { - const { buttonGroup: groupTheme, button: buttonTheme } = getTheme(); - const theme = mergeDeep(buttonTheme, customTheme); + href?: string; + color?: keyof FlowbiteColors; + fullSized?: boolean; + gradientDuoTone?: keyof ButtonGradientDuoToneColors; + gradientMonochrome?: keyof ButtonGradientColors; + target?: string; + isProcessing?: boolean; + processingLabel?: string; + processingSpinner?: ReactNode; + label?: ReactNode; + outline?: boolean; + pill?: boolean; + positionInGroup?: keyof PositionInButtonGroup; + size?: keyof ButtonSizes; + theme?: DeepPartial; + } +>; + +type ButtonComponentType = (( + props: ButtonProps, +) => React.ReactNode | null) & { displayName?: string }; + +const ButtonComponentFn: ButtonComponentType = forwardRef( + ( + { + children, + className, + color = 'info', + disabled, + fullSized, + isProcessing = false, + processingLabel = 'Loading...', + processingSpinner, + gradientDuoTone, + gradientMonochrome, + label, + outline = false, + pill = false, + positionInGroup = 'none', + size = 'md', + theme: customTheme = {}, + ...props + }: ButtonProps, + ref: PolymorphicRef, + ) => { + const { buttonGroup: groupTheme, button: buttonTheme } = getTheme(); + const theme = mergeDeep(buttonTheme, customTheme); - const theirProps = props as ButtonBaseProps; + const theirProps = props as ButtonBaseProps; - return ( - - - <> - {isProcessing && ( - - {processingSpinner || } - + - {isProcessing ? processingLabel : label} - - )} - - - - ); -}; + > + <> + {isProcessing && ( + + {processingSpinner || } + + )} + {typeof children !== 'undefined' ? ( + children + ) : ( + + {isProcessing ? processingLabel : label} + + )} + + + + ); + }, +); ButtonComponentFn.displayName = 'Button'; - -const ButtonComponent = genericForwardRef(ButtonComponentFn); - -export const Button = Object.assign(ButtonComponent, { +export const Button = Object.assign(ButtonComponentFn, { Group: ButtonGroup, }); diff --git a/src/components/Button/ButtonBase.tsx b/src/components/Button/ButtonBase.tsx index 42bd13799..6bb5a97c1 100644 --- a/src/components/Button/ButtonBase.tsx +++ b/src/components/Button/ButtonBase.tsx @@ -1,18 +1,19 @@ -import { createElement, type ComponentPropsWithoutRef, type ElementType, type ForwardedRef } from 'react'; -import genericForwardRef from '../../helpers/generic-forward-ref'; +import { createElement, type ComponentPropsWithoutRef, type ElementType, type ForwardedRef, forwardRef } from 'react'; export type ButtonBaseProps = { as?: T; href?: string; } & ComponentPropsWithoutRef; -const ButtonBaseComponent = ( - { children, as: Component, href, type = 'button', ...props }: ButtonBaseProps, - ref: ForwardedRef, -) => { - const BaseComponent = Component || (href ? 'a' : 'button'); +export const ButtonBase = forwardRef( + ( + { children, as: Component, href, type = 'button', ...props }: ButtonBaseProps, + ref: ForwardedRef, + ) => { + const BaseComponent = Component || (href ? 'a' : 'button'); - return createElement(BaseComponent, { ref, href, type, ...props }, children); -}; + return createElement(BaseComponent, { ref, href, type, ...props }, children); + }, +); -export const ButtonBase = genericForwardRef(ButtonBaseComponent); +ButtonBase.displayName = 'ButtonBaseComponent'; diff --git a/src/components/Dropdown/Dropdown.spec.tsx b/src/components/Dropdown/Dropdown.spec.tsx index c5f1895c8..fec8846ae 100644 --- a/src/components/Dropdown/Dropdown.spec.tsx +++ b/src/components/Dropdown/Dropdown.spec.tsx @@ -167,7 +167,7 @@ describe('Components / Dropdown', () => { }); describe('Dropdown item render', async () => { - it('should override Dropdownn.Item base component when using `as` prop', async () => { + it('should override Dropdown.Item base component when using `as` prop', async () => { const user = userEvent.setup(); const CustomBaseItem = ({ children }: PropsWithChildren) => { diff --git a/src/components/Dropdown/Dropdown.tsx b/src/components/Dropdown/Dropdown.tsx index 8398785b1..3b34ac8f1 100644 --- a/src/components/Dropdown/Dropdown.tsx +++ b/src/components/Dropdown/Dropdown.tsx @@ -10,7 +10,6 @@ import type { MutableRefObject, ReactElement, ReactNode, - RefCallback, SetStateAction, } from 'react'; import { cloneElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -98,13 +97,7 @@ const Trigger = ({ {children} ) : ( - ); diff --git a/src/components/Dropdown/DropdownItem.tsx b/src/components/Dropdown/DropdownItem.tsx index 673304c62..2c36c3206 100644 --- a/src/components/Dropdown/DropdownItem.tsx +++ b/src/components/Dropdown/DropdownItem.tsx @@ -1,12 +1,13 @@ 'use client'; -import { useListItem } from '@floating-ui/react'; -import type { ComponentProps, ComponentPropsWithoutRef, ElementType, FC, RefCallback } from 'react'; +import { useListItem, useMergeRefs } from '@floating-ui/react'; +import { forwardRef, type ComponentProps, type ElementType, type FC, type RefCallback } from 'react'; import { twMerge } from 'tailwind-merge'; import { mergeDeep } from '../../helpers/merge-deep'; import type { DeepPartial } from '../../types'; import { ButtonBase, type ButtonBaseProps } from '../Button/ButtonBase'; import { useDropdownContext } from './DropdownContext'; +import type { PolymorphicComponentPropWithRef, PolymorphicRef } from '~/src/helpers/generic-as-prop'; export interface FlowbiteDropdownItemTheme { container: string; @@ -14,47 +15,52 @@ export interface FlowbiteDropdownItemTheme { icon: string; } -export type DropdownItemProps = { - // TODO: make it work with `Link` from Next.js - as?: T; - href?: string; - icon?: FC>; - onClick?: () => void; - theme?: DeepPartial; -} & ComponentPropsWithoutRef; +export type DropdownItemProps = PolymorphicComponentPropWithRef< + T, + { + href?: string; + icon?: FC>; + onClick?: () => void; + theme?: DeepPartial; + } +>; -export const DropdownItem = ({ - children, - className, - icon: Icon, - onClick, - theme: customTheme = {}, - ...props -}: DropdownItemProps) => { - const { ref, index } = useListItem({ label: typeof children === 'string' ? children : undefined }); - const { theme: rootTheme, activeIndex, dismissOnClick, getItemProps, handleSelect } = useDropdownContext(); - const isActive = activeIndex === index; - const theme = mergeDeep(rootTheme.floating.item, customTheme); +type DropdownItemComponentType = (( + props: DropdownItemProps, +) => React.ReactNode | null) & { displayName?: string }; - const theirProps = props as ButtonBaseProps; +export const DropdownItem: DropdownItemComponentType = forwardRef( + ( + { children, className, icon: Icon, onClick, theme: customTheme = {}, ...props }: DropdownItemProps, + forwardedRef: PolymorphicRef, + ) => { + const { ref: listItemRef, index } = useListItem({ label: typeof children === 'string' ? children : undefined }); + const ref = useMergeRefs([forwardedRef, listItemRef]); + const { theme: rootTheme, activeIndex, dismissOnClick, getItemProps, handleSelect } = useDropdownContext(); + const isActive = activeIndex === index; + const theme = mergeDeep(rootTheme.floating.item, customTheme); - return ( -
  • - } - className={twMerge(theme.base, className)} - {...theirProps} - {...getItemProps({ - onClick: () => { - onClick && onClick(); - dismissOnClick && handleSelect(null); - }, - })} - tabIndex={isActive ? 0 : -1} - > - {Icon && } - {children} - -
  • - ); -}; + const theirProps = props as ButtonBaseProps; + + return ( +
  • + } + className={twMerge(theme.base, className)} + {...theirProps} + {...getItemProps({ + onClick: () => { + onClick && onClick(); + dismissOnClick && handleSelect(null); + }, + })} + tabIndex={isActive ? 0 : -1} + > + {Icon && } + {children} + +
  • + ); + }, +); +DropdownItem.displayName = 'DropdownItem'; diff --git a/src/helpers/generic-as-prop.ts b/src/helpers/generic-as-prop.ts new file mode 100644 index 000000000..53c65add8 --- /dev/null +++ b/src/helpers/generic-as-prop.ts @@ -0,0 +1,23 @@ +import type React from 'react'; + +export type AsProp = { + as?: C | null; +}; + +export type PropsToOmit = keyof (AsProp & P); + +// eslint-disable-next-line @typescript-eslint/ban-types +export type PolymorphicComponentProp = React.PropsWithChildren< + Props & AsProp +> & + Omit, PropsToOmit>; + +// eslint-disable-next-line @typescript-eslint/ban-types +export type PolymorphicComponentPropWithRef = PolymorphicComponentProp< + C, + Props +> & { + ref?: PolymorphicRef; +}; + +export type PolymorphicRef = React.ComponentPropsWithRef['ref']; diff --git a/src/helpers/generic-forward-ref.ts b/src/helpers/generic-forward-ref.ts deleted file mode 100644 index 28460fa5e..000000000 --- a/src/helpers/generic-forward-ref.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type React from 'react'; -import { forwardRef } from 'react'; - -/** This allow the `forwardRef` to be used with generic components */ - -// eslint-disable-next-line @typescript-eslint/ban-types -type FixedForwardRef = ( - render: (props: P, ref: React.Ref) => JSX.Element, -) => (props: P & React.RefAttributes) => JSX.Element; - -const genericForwardRef = forwardRef as FixedForwardRef; - -export default genericForwardRef;