Skip to content

Commit 942735e

Browse files
authored
fix(button.tsx): Button as prop internal logic + TS props (#885)
* fix(button.tsx): correctly set base tag for button when href is provided In Next the Link component uses `href`. The preivous condition was always renderinng the button as achor tag when href was provided. Now if `as` is provided it always render the defined tag/component fix #865 * refact(button.tsx): make dropdownItem and button `as` props to inherit the props of the tag/component passed * docs(button.tsx): updated href in the Next link button example * docs(dropdownitem.tsx): added `as` prop to the custom item examples * refact(dropdown.tsx): fix ts errors from button props
1 parent ebe605e commit 942735e

File tree

9 files changed

+157
-120
lines changed

9 files changed

+157
-120
lines changed

app/docs/components/button/button.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ The `as` prop provides you the ability to transform the `<Button />` component i
243243
<Button as="span" className="cursor-pointer">Span Button</Button>
244244
</div>
245245
<div>
246-
<Button as={Link} href="#">
246+
<Button as={Link} href="/">
247247
Next Link Button
248248
</Button>
249249
</div>
@@ -256,7 +256,7 @@ The `as` prop provides you the ability to transform the `<Button />` component i
256256
</Button>
257257
</div>
258258
<div>
259-
<Button as={Link} href="#">
259+
<Button as={Link} href="/">
260260
Next Link Button
261261
</Button>
262262
</div>

app/docs/components/dropdown/dropdown.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ To customize the `Dropdown.Item` base element you can use the `as` property.
203203
<Dropdown.Item as={Link} href="/">
204204
Home
205205
</Dropdown.Item>
206-
<Dropdown.Item href="https://flowbite.com/" target="_blank">
206+
<Dropdown.Item as="a" href="https://flowbite.com/" target="_blank">
207207
External link
208208
</Dropdown.Item>
209209
</Dropdown>`}
@@ -212,7 +212,7 @@ To customize the `Dropdown.Item` base element you can use the `as` property.
212212
<Dropdown.Item as={Link} href="/">
213213
Home
214214
</Dropdown.Item>
215-
<Dropdown.Item href="https://flowbite.com/" target="_blank">
215+
<Dropdown.Item as="a" href="https://flowbite.com/" target="_blank">
216216
External link
217217
</Dropdown.Item>
218218
</Dropdown>

src/components/Button/Button.spec.tsx

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -125,41 +125,55 @@ describe('Components / Button', () => {
125125
expect(buttonLink()).toBeInTheDocument();
126126
});
127127

128-
it('should render an anchor `<a>` when `href=".."` even though `as` is defined', () => {
129-
render(<Button href="#" as="label" label="Something or other" />);
130-
131-
expect(buttonLink()).toBeInTheDocument();
132-
});
128+
it('should render component defined in `as`', () => {
129+
const CustomComponent = ({ children }: PropsWithChildren<{ uniqueProp: boolean }>) => {
130+
return <li>{children}</li>;
131+
};
133132

134-
it('should render tag element defined in `as`', () => {
135133
render(
136134
<ul>
137-
<Button as="li" label="Something or other" />
135+
<Button as={CustomComponent} uniqueProp>
136+
Something or other
137+
</Button>
138138
</ul>,
139139
);
140140

141-
expect(buttonListItem()).toBeInTheDocument();
141+
const button = buttonListItem();
142+
143+
expect(button).toBeInTheDocument();
144+
expect(button).toHaveTextContent('Something or other');
142145
});
143146

144-
it('should render component defined in `as`', () => {
147+
it('should render component defined in `as` prop even though `href` is defined', () => {
145148
const CustomComponent = ({ children }: PropsWithChildren) => {
146149
return <li>{children}</li>;
147150
};
148151

149152
render(
150153
<ul>
151-
<Button as={CustomComponent} label="Something or other" />
154+
<Button href="#" as={CustomComponent} label="Something or other" />
152155
</ul>,
153156
);
154157

155-
const button = buttonListItem();
158+
expect(buttonListItem()).toBeInTheDocument();
159+
});
156160

157-
expect(button).toBeInTheDocument();
158-
expect(button).toHaveTextContent('Something or other');
161+
it('should render tag element defined in `as`', () => {
162+
render(
163+
<ul>
164+
<Button as="li" label="Something or other" />
165+
</ul>,
166+
);
167+
168+
expect(buttonListItem()).toBeInTheDocument();
159169
});
160170

161-
it('should render as button when `as`={null}', () => {
162-
render(<Button as={null} label="Something or other" />);
171+
it('should render as button `as={null}`', () => {
172+
render(
173+
<ul>
174+
<Button as={null as any} label="Something or other" />
175+
</ul>,
176+
);
163177

164178
expect(button()).toBeInTheDocument();
165179
});

src/components/Button/Button.tsx

Lines changed: 78 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import { forwardRef, type ReactNode } from 'react';
1+
import type { ComponentPropsWithoutRef, ElementType, ForwardedRef } from 'react';
2+
import { type ReactNode } from 'react';
23
import { twMerge } from 'tailwind-merge';
4+
import genericForwardRef from '~/src/helpers/generic-forward-ref';
35
import type {
46
DeepPartial,
57
FlowbiteBoolean,
@@ -64,7 +66,9 @@ export interface ButtonSizes extends Pick<FlowbiteSizes, 'xs' | 'sm' | 'lg' | 'x
6466
[key: string]: string;
6567
}
6668

67-
export interface ButtonProps extends ButtonBaseProps {
69+
export type ButtonProps<T extends ElementType = 'button'> = {
70+
as?: T;
71+
href?: string;
6872
color?: keyof FlowbiteColors;
6973
fullSized?: boolean;
7074
gradientDuoTone?: keyof ButtonGradientDuoToneColors;
@@ -79,88 +83,85 @@ export interface ButtonProps extends ButtonBaseProps {
7983
positionInGroup?: keyof PositionInButtonGroup;
8084
size?: keyof ButtonSizes;
8185
theme?: DeepPartial<FlowbiteButtonTheme>;
82-
}
83-
84-
interface Props extends ButtonProps, Record<string, unknown> {}
86+
} & ComponentPropsWithoutRef<T>;
8587

86-
const ButtonComponent = forwardRef<HTMLButtonElement | HTMLAnchorElement, Props>(
87-
(
88-
{
89-
children,
90-
className,
91-
color = 'info',
92-
disabled = false,
93-
fullSized,
94-
isProcessing = false,
95-
processingLabel = 'Loading...',
96-
processingSpinner,
97-
gradientDuoTone,
98-
gradientMonochrome,
99-
label,
100-
outline = false,
101-
pill = false,
102-
positionInGroup = 'none',
103-
size = 'md',
104-
theme: customTheme = {},
105-
...props
106-
},
107-
ref,
108-
) => {
109-
const { buttonGroup: groupTheme, button: buttonTheme } = useTheme().theme;
110-
const theme = mergeDeep(buttonTheme, customTheme);
88+
const ButtonComponentFn = <T extends ElementType = 'button'>(
89+
{
90+
children,
91+
className,
92+
color = 'info',
93+
disabled,
94+
fullSized,
95+
isProcessing = false,
96+
processingLabel = 'Loading...',
97+
processingSpinner,
98+
gradientDuoTone,
99+
gradientMonochrome,
100+
label,
101+
outline = false,
102+
pill = false,
103+
positionInGroup = 'none',
104+
size = 'md',
105+
theme: customTheme = {},
106+
...props
107+
}: ButtonProps<T>,
108+
ref: ForwardedRef<T>,
109+
) => {
110+
const { buttonGroup: groupTheme, button: buttonTheme } = useTheme().theme;
111+
const theme = mergeDeep(buttonTheme, customTheme);
111112

112-
const theirProps = props as object;
113+
const theirProps = props as ButtonBaseProps<T>;
113114

114-
return (
115-
<ButtonBase
116-
disabled={disabled}
117-
ref={ref as never}
115+
return (
116+
<ButtonBase
117+
ref={ref}
118+
disabled={disabled}
119+
className={twMerge(
120+
theme.base,
121+
disabled && theme.disabled,
122+
!gradientDuoTone && !gradientMonochrome && theme.color[color],
123+
gradientDuoTone && !gradientMonochrome && theme.gradientDuoTone[gradientDuoTone],
124+
!gradientDuoTone && gradientMonochrome && theme.gradient[gradientMonochrome],
125+
outline && (theme.outline.color[color] ?? theme.outline.color.default),
126+
theme.pill[pill ? 'on' : 'off'],
127+
fullSized && theme.fullSized,
128+
groupTheme.position[positionInGroup],
129+
className,
130+
)}
131+
{...theirProps}
132+
>
133+
<span
118134
className={twMerge(
119-
theme.base,
120-
disabled && theme.disabled,
121-
!gradientDuoTone && !gradientMonochrome && theme.color[color],
122-
gradientDuoTone && !gradientMonochrome && theme.gradientDuoTone[gradientDuoTone],
123-
!gradientDuoTone && gradientMonochrome && theme.gradient[gradientMonochrome],
124-
outline && (theme.outline.color[color] ?? theme.outline.color.default),
125-
theme.pill[pill ? 'on' : 'off'],
126-
fullSized && theme.fullSized,
127-
groupTheme.position[positionInGroup],
128-
className,
135+
theme.inner.base,
136+
theme.outline[outline ? 'on' : 'off'],
137+
theme.outline.pill[outline && pill ? 'on' : 'off'],
138+
theme.size[size],
139+
outline && !theme.outline.color[color] && theme.inner.outline,
140+
isProcessing && theme.isProcessing,
141+
isProcessing && theme.inner.isProcessingPadding[size],
142+
theme.inner.position[positionInGroup],
129143
)}
130-
{...theirProps}
131144
>
132-
<span
133-
className={twMerge(
134-
theme.inner.base,
135-
theme.outline[outline ? 'on' : 'off'],
136-
theme.outline.pill[outline && pill ? 'on' : 'off'],
137-
theme.size[size],
138-
outline && !theme.outline.color[color] && theme.inner.outline,
139-
isProcessing && theme.isProcessing,
140-
isProcessing && theme.inner.isProcessingPadding[size],
141-
theme.inner.position[positionInGroup],
145+
<>
146+
{isProcessing && (
147+
<span className={twMerge(theme.spinnerSlot, theme.spinnerLeftPosition[size])}>
148+
{processingSpinner || <Spinner size={size} />}
149+
</span>
142150
)}
143-
>
144-
<>
145-
{isProcessing && (
146-
<span className={twMerge(theme.spinnerSlot, theme.spinnerLeftPosition[size])}>
147-
{processingSpinner || <Spinner size={size} />}
148-
</span>
149-
)}
150-
{typeof children !== 'undefined' ? (
151-
children
152-
) : (
153-
<span data-testid="flowbite-button-label" className={twMerge(theme.label)}>
154-
{isProcessing ? processingLabel : label}
155-
</span>
156-
)}
157-
</>
158-
</span>
159-
</ButtonBase>
160-
);
161-
},
162-
);
163-
ButtonComponent.displayName = 'ButtonComponent';
151+
{typeof children !== 'undefined' ? (
152+
children
153+
) : (
154+
<span data-testid="flowbite-button-label" className={twMerge(theme.label)}>
155+
{isProcessing ? processingLabel : label}
156+
</span>
157+
)}
158+
</>
159+
</span>
160+
</ButtonBase>
161+
);
162+
};
163+
164+
const ButtonComponent = genericForwardRef(ButtonComponentFn);
164165

165166
export const Button = Object.assign(ButtonComponent, {
166167
Group: ButtonGroup,
Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
1-
import { createElement, forwardRef, type ComponentProps, type ElementType } from 'react';
1+
import { createElement, type ComponentPropsWithoutRef, type ElementType, type ForwardedRef } from 'react';
2+
import genericForwardRef from '../../helpers/generic-forward-ref';
23

3-
export interface ButtonBaseProps extends Omit<ComponentProps<'button'>, 'color' | 'ref'> {
4-
as?: ElementType;
4+
export type ButtonBaseProps<T extends ElementType = 'button'> = {
5+
as?: T;
56
href?: string;
6-
}
7+
} & ComponentPropsWithoutRef<T>;
78

8-
interface Props extends ButtonBaseProps, Record<string, unknown> {}
9+
const ButtonBaseComponent = <T extends ElementType = 'button'>(
10+
{ children, as: Component, href, type = 'button', ...props }: ButtonBaseProps<T>,
11+
ref: ForwardedRef<T>,
12+
) => {
13+
const BaseComponent = Component || (href ? 'a' : 'button');
914

10-
export const ButtonBase = forwardRef<HTMLButtonElement | HTMLAnchorElement, Props>(
11-
({ children, as: Component = 'button', href, ...props }, ref) => {
12-
const BaseComponent = href ? 'a' : Component ?? 'button';
13-
const type = Component === 'button' ? 'button' : undefined;
15+
return createElement(BaseComponent, { ref, href, type, ...props }, children);
16+
};
1417

15-
return createElement(BaseComponent, { ref, href, type, ...props }, children);
16-
},
17-
);
18-
ButtonBase.displayName = 'Button';
18+
export const ButtonBase = genericForwardRef(ButtonBaseComponent);

src/components/Dropdown/Dropdown.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ CustomItem.args = {
9393
<Dropdown.Item>Default button</Dropdown.Item>
9494
<Dropdown.Item as="span">As span</Dropdown.Item>
9595
<Dropdown.Divider />
96-
<Dropdown.Item href="https://flowbite.com/" target="_blank">
96+
<Dropdown.Item as="a" href="https://flowbite.com/" target="_blank">
9797
As link
9898
</Dropdown.Item>
9999
</>

src/components/Dropdown/Dropdown.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
PropsWithChildren,
1010
ReactElement,
1111
ReactNode,
12+
RefCallback,
1213
SetStateAction,
1314
} from 'react';
1415
import { cloneElement, createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -101,7 +102,13 @@ const Trigger = ({
101102
{children}
102103
</button>
103104
) : (
104-
<Button {...buttonProps} disabled={disabled} type="button" ref={refs.setReference} {...a11yProps}>
105+
<Button
106+
{...buttonProps}
107+
disabled={disabled}
108+
type="button"
109+
ref={refs.setReference as RefCallback<'button'>}
110+
{...a11yProps}
111+
>
105112
{children}
106113
</Button>
107114
);
@@ -247,7 +254,6 @@ const DropdownComponent: FC<DropdownProps> = ({
247254
};
248255

249256
DropdownComponent.displayName = 'Dropdown';
250-
DropdownItem.displayName = 'Dropdown.Item';
251257
DropdownHeader.displayName = 'Dropdown.Header';
252258
DropdownDivider.displayName = 'Dropdown.Divider';
253259

0 commit comments

Comments
 (0)