Skip to content

Commit 7714edf

Browse files
committed
fix(button): improve Button handling of link behavior
1 parent 15f0c9f commit 7714edf

File tree

4 files changed

+161
-13
lines changed

4 files changed

+161
-13
lines changed

packages/paste-core/components/button/__tests__/button.test.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,55 @@ describe('Button Errors', () => {
160160
</Button>
161161
)
162162
).toThrow();
163+
expect(() =>
164+
shallow(
165+
<Button as="a" variant="inverse_link" href={HREF}>
166+
Go to Paste
167+
</Button>
168+
)
169+
).toThrow();
170+
});
171+
172+
it('Throws an error when an "a" tag is passed but not using correct variant', () => {
173+
expect(() =>
174+
shallow(
175+
<Button as="a" href="#" variant="destructive">
176+
Go to Paste
177+
</Button>
178+
)
179+
).toThrow();
180+
expect(() =>
181+
shallow(
182+
<Button as="a" href="#" variant="destructive_secondary">
183+
Go to Paste
184+
</Button>
185+
)
186+
).toThrow();
187+
expect(() =>
188+
shallow(
189+
<Button as="a" href="#" variant="inverse">
190+
Go to Paste
191+
</Button>
192+
)
193+
).toThrow();
194+
});
195+
196+
it('Throws an error when an "a" tag is passed with disabled or loading state', () => {
197+
expect(() =>
198+
shallow(
199+
<Button as="a" href="#" variant="primary" disabled>
200+
Go to Paste
201+
</Button>
202+
)
203+
).toThrow();
204+
205+
expect(() =>
206+
shallow(
207+
<Button as="a" href="#" variant="primary" loading>
208+
Go to Paste
209+
</Button>
210+
)
211+
).toThrow();
163212
});
164213

165214
it('Throws an error when size=reset is not applied to variant=reset', () => {

packages/paste-core/components/button/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
"lodash.merge": "^4.6.2"
2929
},
3030
"peerDependencies": {
31+
"@twilio-paste/anchor": "^5.0.1",
32+
"@twilio-paste/animation-library": "^0.3.1",
3133
"@twilio-paste/box": "4.0.2",
3234
"@twilio-paste/design-tokens": "6.6.0",
3335
"@twilio-paste/icons": "5.1.1",
@@ -43,6 +45,8 @@
4345
"react-dom": "^16.8.6"
4446
},
4547
"devDependencies": {
48+
"@twilio-paste/anchor": "^5.0.1",
49+
"@twilio-paste/animation-library": "^0.3.1",
4650
"@twilio-paste/box": "4.0.2",
4751
"@twilio-paste/design-tokens": "6.6.0",
4852
"@twilio-paste/icons": "5.1.1",

packages/paste-core/components/button/src/index.tsx

Lines changed: 78 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import * as React from 'react';
22
import {Box} from '@twilio-paste/box';
33
import {Stack} from '@twilio-paste/stack';
44
import {Spinner} from '@twilio-paste/spinner';
5+
import {secureExternalLink} from '@twilio-paste/anchor';
6+
import {useSpring, animated} from '@twilio-paste/animation-library';
7+
import {ArrowForwardIcon} from '@twilio-paste/icons/esm/ArrowForwardIcon';
58
import type {
69
ButtonProps,
710
ButtonSizes,
@@ -21,6 +24,8 @@ import {InverseButton} from './InverseButton';
2124
import {InverseLinkButton} from './InverseLinkButton';
2225
import {ResetButton} from './ResetButton';
2326

27+
const AnimatedBox = animated(Box);
28+
2429
// If size isn't passed, come up with a smart default:
2530
// - 'reset' for variant 'link'
2631
// - 'icon' if there's 1 child that's an icon
@@ -29,12 +34,12 @@ const getButtonSize = (variant: ButtonVariants, children: React.ReactNode, size?
2934
let smartSize: ButtonSizes = 'default';
3035
if (size != null) {
3136
smartSize = size;
32-
} else if (variant === 'link' || variant === 'destructive_link') {
37+
} else if (variant === 'link' || variant === 'destructive_link' || variant === 'reset') {
3338
smartSize = 'reset';
3439
} else if (React.Children.count(children) === 1) {
3540
React.Children.forEach(children, (child) => {
3641
if (React.isValidElement(child)) {
37-
// @ts-ignore
42+
// @ts-expect-error we know displayName will exist in React
3843
if (typeof child.type.displayName === 'string' && child.type.displayName.includes('Icon')) {
3944
smartSize = 'icon';
4045
}
@@ -54,25 +59,50 @@ const getButtonState = (disabled?: boolean, loading?: boolean): ButtonStates =>
5459
return 'default';
5560
};
5661

57-
const handlePropValidation = ({as, href, tabIndex, variant, size, fullWidth, children}: ButtonProps): void => {
62+
const handlePropValidation = ({
63+
as,
64+
href,
65+
tabIndex,
66+
variant,
67+
size,
68+
fullWidth,
69+
children,
70+
disabled,
71+
loading,
72+
}: ButtonProps): void => {
5873
const hasHref = href != null && href !== '';
5974
const hasTabIndex = tabIndex != null;
6075

76+
// Link validation
6177
if (as !== 'a' && hasHref) {
6278
throw new Error(`[Paste: Button] You cannot pass href into a button without the 'a' tag. Use 'as="a"'.`);
6379
}
64-
if (as === 'a' && !hasHref) {
65-
throw new Error(`[Paste: Button] Missing href prop for link button.`);
66-
}
67-
if (as === 'a' && variant === 'link') {
68-
throw new Error(`[Paste: Button] This should be a link. Use the [Paste: Anchor] component.`);
80+
if (as === 'a') {
81+
if (!hasHref) {
82+
throw new Error(`[Paste: Button] Missing href prop for link button.`);
83+
}
84+
if (variant === 'link' || variant === 'inverse_link') {
85+
throw new Error(`[Paste: Button] Using Button component as an Anchor. Use the Paste Anchor component instead.`);
86+
}
87+
if (variant !== 'primary' && variant !== 'secondary' && variant !== 'reset') {
88+
throw new Error(`[Paste: Button] <Button as="a"> only works with the following variants: primary or secondary.`);
89+
}
90+
if (disabled || loading) {
91+
throw new Error(`[Paste: Button] <Button as="a"> cannot be disabled or loading.`);
92+
}
6993
}
94+
95+
// Reset validation
7096
if (variant === 'reset' && size !== 'reset') {
7197
throw new Error('[Paste: Button] The "RESET" variant can only be used with the "RESET" size.');
7298
}
99+
100+
// Icon validation
73101
if ((size === 'icon' || size === 'icon_small') && fullWidth) {
74102
throw new Error('[Paste: Button] Icon buttons should not be fullWidth.');
75103
}
104+
105+
// Button validation
76106
if (children == null) {
77107
throw new Error(`[Paste: Button] Must have non-null children.`);
78108
}
@@ -147,20 +177,55 @@ const getButtonComponent = (variant: ButtonVariants): React.FunctionComponent<Di
147177
// memo
148178
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
149179
const {size, variant, children, disabled, loading, ...rest} = props;
180+
const [hovered, setHovered] = React.useState(false);
181+
const arrowIconStyles = useSpring({
182+
translateX: hovered ? '4px' : '0px',
183+
config: {
184+
mass: 0.1,
185+
tension: 275,
186+
friction: 16,
187+
},
188+
});
189+
190+
const smartDefaultSize = React.useMemo(() => {
191+
return getButtonSize(variant, children, size);
192+
}, [size, variant, children]);
150193

151-
handlePropValidation(props);
194+
handlePropValidation({...props, size: smartDefaultSize});
152195

153196
const buttonState = getButtonState(disabled, loading);
154197
const showLoading = buttonState === 'loading';
155198
const showDisabled = buttonState !== 'default';
156199
const ButtonComponent = getButtonComponent(variant);
157-
const smartDefaultSize = React.useMemo(() => {
158-
return getButtonSize(variant, children, size);
159-
}, [size, variant, children]);
200+
// Automatically inject AnchorForwardIcon for link's dressed as buttons when possible
201+
const injectIconChildren =
202+
props.as === 'a' && props.href != null && typeof children === 'string' && variant !== 'reset' ? (
203+
<>
204+
{children}
205+
<AnimatedBox style={arrowIconStyles}>
206+
<ArrowForwardIcon decorative />
207+
</AnimatedBox>
208+
</>
209+
) : (
210+
children
211+
);
160212

161213
return (
162214
<ButtonComponent
215+
{...(rest.href != null ? secureExternalLink(rest.href) : null)}
163216
{...rest}
217+
onMouseEnter={(event) => {
218+
if (typeof rest.onMouseEnter === 'function') {
219+
rest.onMouseEnter(event);
220+
}
221+
setHovered(true);
222+
}}
223+
onMouseLeave={(event) => {
224+
if (typeof rest.onMouseLeave === 'function') {
225+
rest.onMouseLeave(event);
226+
}
227+
setHovered(false);
228+
}}
164229
buttonState={buttonState}
165230
disabled={showDisabled}
166231
size={smartDefaultSize as ButtonSizes}
@@ -170,7 +235,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>((props, ref) =>
170235
ref={ref}
171236
>
172237
<ButtonContents buttonState={buttonState} showLoading={showLoading} variant={variant}>
173-
{children}
238+
{injectIconChildren}
174239
</ButtonContents>
175240
</ButtonComponent>
176241
);

packages/paste-core/components/button/stories/index.stories.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,33 @@ export const Reset = (): React.ReactNode => (
128128
</Heading>
129129
</>
130130
);
131+
export const ButtonAsAnchor = (): React.ReactNode => {
132+
return (
133+
<>
134+
<Box padding="space30">
135+
<Button as="a" href="https://twilio.com" variant="primary">
136+
Automatically adds link icon
137+
</Button>
138+
</Box>
139+
<Box padding="space30">
140+
<Button as="a" href="https://twilio.com" variant="secondary">
141+
Automatically adds link icon
142+
</Button>
143+
</Box>
144+
<Box padding="space30">
145+
<Button as="a" href="https://twilio.com" variant="reset">
146+
Not added on reset variant
147+
</Button>
148+
</Box>
149+
150+
<Box padding="space30">
151+
<Button as="a" href="https://twilio.com" variant="primary">
152+
Not added when children{' '}
153+
<em>
154+
<u>is not string type</u>
155+
</em>
156+
</Button>
157+
</Box>
158+
</>
159+
);
160+
};

0 commit comments

Comments
 (0)