Skip to content

Commit 4a6ad58

Browse files
committed
ref(js): Factor button functionality
- Move `useButtonFunctionality` into it's own module, keeping index.tsx specifc to JUST what is required to render the Button, and not sharing anything with linkButton - Move common styles out of the index module into styles.tsx, renames index.chonk to styles.chonk as well. - Move common types into types.tsx - Renames `getChonkButtonStyles` to `DO_NOT_USE_getChonkButtonStyles` to match the `DO_NOT_USE_getButtonStyles`
1 parent fd285c0 commit 4a6ad58

File tree

7 files changed

+352
-344
lines changed

7 files changed

+352
-344
lines changed
Lines changed: 11 additions & 331 deletions
Original file line numberDiff line numberDiff line change
@@ -1,81 +1,16 @@
11
import isPropValid from '@emotion/is-prop-valid';
2-
import type {SerializedStyles, Theme} from '@emotion/react';
3-
import {css} from '@emotion/react';
42
import styled from '@emotion/styled';
53

6-
import {type LinkButtonProps} from 'sentry/components/core/button/linkButton';
7-
import {Tooltip, type TooltipProps} from 'sentry/components/core/tooltip';
4+
import {Tooltip} from 'sentry/components/core/tooltip';
85
import InteractionStateLayer from 'sentry/components/interactionStateLayer';
9-
import type {SVGIconProps} from 'sentry/icons/svgIcon';
106
import {IconDefaultsProvider} from 'sentry/icons/useIconDefaults';
11-
import HookStore from 'sentry/stores/hookStore';
127
import {space} from 'sentry/styles/space';
138

14-
import {getChonkButtonStyles} from './index.chonk';
9+
import {DO_NOT_USE_BUTTON_ICON_SIZES, DO_NOT_USE_getButtonStyles} from './styles';
10+
import {DO_NOT_USE_getChonkButtonStyles} from './styles.chonk';
11+
import type {DO_NOT_USE_CommonButtonProps} from './types';
12+
import {useButtonFunctionality} from './useButtonFunctionality';
1513

16-
// We do not want people using this type as it should only be used
17-
// internally by the different button implementations
18-
// eslint-disable-next-line @typescript-eslint/naming-convention
19-
export interface DO_NOT_USE_CommonButtonProps {
20-
/**
21-
* Used when you want to overwrite the default Reload event key for analytics
22-
*/
23-
analyticsEventKey?: string;
24-
/**
25-
* Used when you want to send an Amplitude Event. By default, Amplitude events are not sent so
26-
* you must pass in a eventName to send an Amplitude event.
27-
*/
28-
analyticsEventName?: string;
29-
/**
30-
* Adds extra parameters to the analytics tracking
31-
*/
32-
analyticsParams?: Record<string, any>;
33-
/**
34-
* Removes borders from the button.
35-
*/
36-
borderless?: boolean;
37-
/**
38-
* Indicates that the button is "doing" something.
39-
*/
40-
busy?: boolean;
41-
/**
42-
* The icon to render inside of the button. The size will be set
43-
* appropriately based on the size of the button.
44-
*/
45-
icon?: React.ReactNode;
46-
/**
47-
* The semantic "priority" of the button. Use `primary` when the action is
48-
* contextually the primary action, `danger` if the button will do something
49-
* destructive, `link` for visual similarity to a link.
50-
*/
51-
priority?: 'default' | 'primary' | 'danger' | 'link';
52-
/**
53-
* The size of the button
54-
*/
55-
size?: 'zero' | 'xs' | 'sm' | 'md';
56-
/**
57-
* Display a tooltip for the button.
58-
*/
59-
title?: TooltipProps['title'];
60-
/**
61-
* Additional properites for the Tooltip when `title` is set.
62-
*/
63-
tooltipProps?: Omit<TooltipProps, 'children' | 'title' | 'skipWrapper'>;
64-
/**
65-
* Userful in scenarios where the border of the button should blend with the
66-
* background behind the button.
67-
*/
68-
translucentBorder?: boolean;
69-
}
70-
71-
/**
72-
* Helper type to extraxct the HTML element props for use in button prop
73-
* interfaces.
74-
*
75-
* XXX(epurkhiser): Right now all usages of this use ButtonElement, but in the
76-
* future ButtonElement should go away and be replaced with HTMLButtonElement
77-
* and HTMLAnchorElement respectively
78-
*/
7914
type ButtonElementProps = Omit<
8015
React.ButtonHTMLAttributes<HTMLButtonElement>,
8116
'label' | 'size' | 'title'
@@ -149,272 +84,27 @@ export function Button({
14984
export const StyledButton = styled('button')<ButtonProps>`
15085
${p =>
15186
p.theme.isChonk
152-
? getChonkButtonStyles(p as any)
87+
? DO_NOT_USE_getChonkButtonStyles(p as any)
15388
: DO_NOT_USE_getButtonStyles(p as any)}
15489
`;
15590

156-
export const useButtonFunctionality = (props: ButtonProps | LinkButtonProps) => {
157-
// Fallbacking aria-label to string children is not necessary as screen
158-
// readers natively understand that scenario. Leaving it here for a bunch of
159-
// our tests that query by aria-label.
160-
const accessibleLabel =
161-
props['aria-label'] ??
162-
(typeof props.children === 'string' ? props.children : undefined);
163-
164-
const useButtonTrackingLogger = () => {
165-
const hasAnalyticsDebug = window.localStorage?.getItem('DEBUG_ANALYTICS') === '1';
166-
const hasCustomAnalytics =
167-
props.analyticsEventName || props.analyticsEventKey || props.analyticsParams;
168-
if (!hasCustomAnalytics || !hasAnalyticsDebug) {
169-
return () => {};
170-
}
171-
172-
return () => {
173-
// eslint-disable-next-line no-console
174-
console.log('buttonAnalyticsEvent', {
175-
eventKey: props.analyticsEventKey,
176-
eventName: props.analyticsEventName,
177-
priority: props.priority,
178-
href: 'href' in props ? props.href : undefined,
179-
...props.analyticsParams,
180-
});
181-
};
182-
};
183-
184-
const useButtonTracking =
185-
HookStore.get('react-hook:use-button-tracking')[0] ?? useButtonTrackingLogger;
186-
187-
const buttonTracking = useButtonTracking({
188-
analyticsEventName: props.analyticsEventName,
189-
analyticsEventKey: props.analyticsEventKey,
190-
analyticsParams: {
191-
priority: props.priority,
192-
href: 'href' in props ? props.href : undefined,
193-
...props.analyticsParams,
194-
},
195-
'aria-label': accessibleLabel || '',
196-
});
197-
198-
const handleClick = (e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
199-
// Don't allow clicks when disabled or busy
200-
if (props.disabled || props.busy) {
201-
e.preventDefault();
202-
e.stopPropagation();
203-
return;
204-
}
205-
206-
buttonTracking();
207-
// @ts-expect-error at this point, we don't know if the button is a button or a link
208-
props.onClick?.(e);
209-
};
210-
211-
const hasChildren = Array.isArray(props.children)
212-
? props.children.some(child => !!child || String(child) === '0')
213-
: !!props.children || String(props.children) === '0';
214-
215-
// Buttons come in 4 flavors: <Link>, <ExternalLink>, <a>, and <button>.
216-
// Let's use props to determine which to serve up, so we don't have to think about it.
217-
// *Note* you must still handle tabindex manually.
218-
219-
return {
220-
handleClick,
221-
hasChildren,
222-
accessibleLabel,
223-
};
224-
};
225-
226-
const getBoxShadow = ({
227-
priority,
228-
borderless,
229-
translucentBorder,
230-
disabled,
231-
size,
232-
theme,
233-
}: (ButtonProps | LinkButtonProps) & {theme: Theme}): SerializedStyles => {
234-
if (disabled || borderless || priority === 'link') {
235-
return css`
236-
box-shadow: none;
237-
`;
238-
}
239-
240-
const themeName = disabled ? 'disabled' : priority || 'default';
241-
const {borderTranslucent} = theme.button[themeName];
242-
const translucentBorderString = translucentBorder
243-
? `0 0 0 1px ${borderTranslucent},`
244-
: '';
245-
const dropShadow = size === 'xs' ? theme.dropShadowLight : theme.dropShadowMedium;
246-
247-
return css`
248-
box-shadow: ${translucentBorderString} ${dropShadow};
249-
&:active {
250-
box-shadow: ${translucentBorderString} inset ${dropShadow};
251-
}
252-
`;
253-
};
254-
255-
const getColors = ({
256-
size,
257-
priority,
258-
disabled,
259-
borderless,
260-
translucentBorder,
261-
theme,
262-
}: (ButtonProps | LinkButtonProps) & {theme: Theme}): SerializedStyles => {
263-
const themeName = disabled ? 'disabled' : priority || 'default';
264-
const {color, colorActive, background, border, borderActive, focusBorder, focusShadow} =
265-
theme.button[themeName];
266-
267-
const getFocusState = (): SerializedStyles => {
268-
switch (priority) {
269-
case 'primary':
270-
case 'danger':
271-
return css`
272-
border-color: ${focusBorder};
273-
box-shadow:
274-
${focusBorder} 0 0 0 1px,
275-
${focusShadow} 0 0 0 4px;
276-
`;
277-
default:
278-
if (translucentBorder) {
279-
return css`
280-
border-color: ${focusBorder};
281-
box-shadow: ${focusBorder} 0 0 0 2px;
282-
`;
283-
}
284-
return css`
285-
border-color: ${focusBorder};
286-
box-shadow: ${focusBorder} 0 0 0 1px;
287-
`;
288-
}
289-
};
290-
291-
return css`
292-
color: ${color};
293-
background-color: ${priority === 'primary' || priority === 'danger'
294-
? background
295-
: borderless
296-
? 'transparent'
297-
: background};
298-
299-
border: 1px solid ${borderless || priority === 'link' ? 'transparent' : border};
300-
301-
${translucentBorder &&
302-
css`
303-
border-width: 0;
304-
`}
305-
306-
&:hover {
307-
color: ${color};
308-
}
309-
310-
${size !== 'zero' &&
311-
css`
312-
&:hover,
313-
&:active,
314-
&[aria-expanded='true'] {
315-
color: ${colorActive || color};
316-
border-color: ${borderless || priority === 'link' ? 'transparent' : borderActive};
317-
}
318-
319-
&:focus-visible {
320-
color: ${colorActive || color};
321-
border-color: ${borderActive};
322-
}
323-
`}
324-
325-
&:focus-visible {
326-
${getFocusState()}
327-
z-index: 1;
328-
}
329-
`;
330-
};
331-
332-
const getSizeStyles = ({
333-
size = 'md',
334-
translucentBorder,
335-
theme,
336-
}: (ButtonProps | LinkButtonProps) & {theme: Theme}): SerializedStyles => {
337-
const buttonSize = size === 'zero' ? 'md' : size;
338-
const formStyles = theme.form[buttonSize];
339-
const buttonPadding = theme.buttonPadding[buttonSize];
340-
341-
// If using translucent borders, rewrite size styles to
342-
// prevent layout shifts
343-
const borderStyles = translucentBorder
344-
? {
345-
height: `calc(${formStyles.height} - 2px)`,
346-
minHeight: `calc(${formStyles.minHeight} - 2px)`,
347-
paddingTop: buttonPadding.paddingTop - 1,
348-
paddingBottom: buttonPadding.paddingBottom - 1,
349-
margin: 1,
350-
}
351-
: {};
352-
353-
return css`
354-
${formStyles}
355-
${buttonPadding}
356-
${borderStyles}
357-
`;
358-
};
359-
360-
// This should only be used by the different underlying button implementations
361-
// and not directly by consumers of the button component.
362-
export function DO_NOT_USE_getButtonStyles(
363-
p: (ButtonProps | LinkButtonProps) & {theme: Theme}
364-
): SerializedStyles {
365-
return css`
366-
position: relative;
367-
display: inline-block;
368-
border-radius: ${p.theme.borderRadius};
369-
text-transform: none;
370-
font-weight: ${p.theme.fontWeightBold};
371-
cursor: ${p.disabled ? 'not-allowed' : 'pointer'};
372-
opacity: ${(p.busy || p.disabled) && '0.65'};
373-
374-
${getColors(p)}
375-
${getSizeStyles(p)}
376-
${getBoxShadow(p)}
377-
378-
transition:
379-
background 0.1s,
380-
border 0.1s,
381-
box-shadow 0.1s;
382-
383-
${p.priority === 'link' &&
384-
css`
385-
font-size: inherit;
386-
font-weight: inherit;
387-
padding: 0;
388-
height: auto;
389-
min-height: auto;
390-
`}
391-
${p.size === 'zero' &&
392-
css`
393-
height: auto;
394-
min-height: auto;
395-
padding: ${space(0.25)};
396-
`}
397-
398-
&:focus {
399-
outline: none;
400-
}
401-
`;
402-
}
403-
40491
const ButtonLabel = styled('span', {
40592
shouldForwardProp: prop =>
40693
typeof prop === 'string' &&
40794
isPropValid(prop) &&
40895
!['size', 'borderless'].includes(prop),
409-
})<Pick<ButtonProps, 'size' | 'borderless'>>`
96+
})<Pick<DO_NOT_USE_CommonButtonProps, 'size' | 'borderless'>>`
41097
height: 100%;
41198
display: flex;
41299
align-items: center;
413100
justify-content: center;
414101
white-space: nowrap;
415102
`;
416103

417-
const Icon = styled('span')<{hasChildren?: boolean; size?: ButtonProps['size']}>`
104+
const Icon = styled('span')<{
105+
hasChildren?: boolean;
106+
size?: DO_NOT_USE_CommonButtonProps['size'];
107+
}>`
418108
display: flex;
419109
align-items: center;
420110
margin-right: ${p =>
@@ -425,13 +115,3 @@ const Icon = styled('span')<{hasChildren?: boolean; size?: ButtonProps['size']}>
425115
: '0'};
426116
flex-shrink: 0;
427117
`;
428-
429-
export const DO_NOT_USE_BUTTON_ICON_SIZES: Record<
430-
NonNullable<BaseButtonProps['size']>,
431-
SVGIconProps['size']
432-
> = {
433-
zero: undefined,
434-
xs: 'xs',
435-
sm: 'sm',
436-
md: 'sm',
437-
};

0 commit comments

Comments
 (0)