import {forwardRef as reactForwardRef, useCallback} from 'react'; import isPropValid from '@emotion/is-prop-valid'; import {css, Theme} from '@emotion/react'; import styled from '@emotion/styled'; import InteractionStateLayer from 'sentry/components/interactionStateLayer'; import ExternalLink from 'sentry/components/links/externalLink'; import Link from 'sentry/components/links/link'; import {Tooltip, TooltipProps} from 'sentry/components/tooltip'; import HookStore from 'sentry/stores/hookStore'; import {space} from 'sentry/styles/space'; import mergeRefs from 'sentry/utils/mergeRefs'; /** * The button can actually also be an anchor or React router Link (which seems * to be poorly typed as `any`). So this is a bit of a workaround to receive * the proper html attributes. */ type ButtonElement = HTMLButtonElement | HTMLAnchorElement; /** * Props shared across different types of button components */ interface CommonButtonProps { /** * Used when you want to overwrite the default Reload event key for analytics */ analyticsEventKey?: string; /** * Used when you want to send an Amplitude Event. By default, Amplitude events are not sent so * you must pass in a eventName to send an Amplitude event. */ analyticsEventName?: string; /** * Adds extra parameters to the analytics tracking */ analyticsParams?: Record; /** * Used by ButtonBar to determine active status. */ barId?: string; /** * Removes borders from the button. */ borderless?: boolean; /** * Indicates that the button is "doing" something. */ busy?: boolean; /** * Disables the button, assigning appropriate aria attributes and disallows * interactions with the button. */ disabled?: boolean; /** * The button is an external link. Similar to the `Link` `external` property. */ external?: boolean; /** * The icon to render inside of the button. The size will be set * appropriately based on the size of the button. */ icon?: React.ReactNode; /** * Used when the button is part of a form. */ name?: string; /** * The semantic "priority" of the button. Use `primary` when the action is * contextually the primary action, `danger` if the button will do something * destructive, `link` for visual similarity to a link. */ priority?: 'default' | 'primary' | 'danger' | 'link'; /** * The size of the button */ size?: 'zero' | 'xs' | 'sm' | 'md'; /** * Display a tooltip for the button. */ title?: TooltipProps['title']; /** * Additional properites for the Tooltip when `title` is set. */ tooltipProps?: Omit; /** * Userful in scenarios where the border of the button should blend with the * background behind the button. */ translucentBorder?: boolean; } /** * Helper type to extraxct the HTML element props for use in button prop * interfaces. * * XXX(epurkhiser): Right now all usages of this use ButtonElement, but in the * future ButtonElement should go away and be replaced with HTMLButtonElement * and HTMLAnchorElement respectively */ type ElementProps = Omit, 'label' | 'size' | 'title'>; interface BaseButtonProps extends CommonButtonProps, ElementProps { /** * For use with `href` and `data:` or `blob:` schemes. Tells the browser to * download the contents. * * See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-download * * @deprecated Use LnikButton instead */ download?: HTMLAnchorElement['download']; /** * @internal Used in the Button forwardRef */ forwardRef?: React.Ref; /** * When set the button acts as an anchor link. Use with `external` to have * the link open in a new tab. * * @deprecated Use LinkButton instead */ href?: string; /** * Similar to `href`, but for internal links within the app. * * @deprecated Use LinkButton instead */ to?: string | object; } interface ButtonPropsWithoutAriaLabel extends BaseButtonProps { children: React.ReactNode; } interface ButtonPropsWithAriaLabel extends BaseButtonProps { 'aria-label': string; children?: never; } type ButtonProps = ButtonPropsWithoutAriaLabel | ButtonPropsWithAriaLabel; interface BaseLinkButtonProps extends CommonButtonProps, ElementProps { /** * @internal Used in the Button forwardRef */ forwardRef?: React.Ref; } interface ToLinkButtonProps extends BaseLinkButtonProps { /** * Similar to `href`, but for internal links within the app. */ to: string | object; } interface HrefLinkButtonProps extends BaseLinkButtonProps { /** * When set the button acts as an anchor link. Use with `external` to have * the link open in a new tab. */ href: string; /** * For use with `href` and `data:` or `blob:` schemes. Tells the browser to * download the contents. * * See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-download */ download?: HTMLAnchorElement['download']; } interface ToLinkButtonPropsWithChildren extends ToLinkButtonProps { children: React.ReactNode; } interface ToLinkButtonPropsWithAriaLabel extends ToLinkButtonProps { 'aria-label': string; children?: never; } interface HrefLinkButtonPropsWithChildren extends HrefLinkButtonProps { children: React.ReactNode; } interface HrefLinkButtonPropsWithAriaLabel extends HrefLinkButtonProps { 'aria-label': string; children?: never; } type LinkButtonProps = | ToLinkButtonPropsWithChildren | ToLinkButtonPropsWithAriaLabel | HrefLinkButtonPropsWithChildren | HrefLinkButtonPropsWithAriaLabel; function BaseButton({ size = 'md', to, busy, href, title, icon, children, 'aria-label': ariaLabel, borderless, translucentBorder, priority, disabled = false, tooltipProps, onClick, analyticsEventName, analyticsEventKey, analyticsParams, ...buttonProps }: ButtonProps) { // Fallbacking aria-label to string children is not necessary as screen // readers natively understand that scenario. Leaving it here for a bunch of // our tests that query by aria-label. const accessibleLabel = ariaLabel ?? (typeof children === 'string' ? children : undefined); const useButtonTrackingLogger = () => { const hasAnalyticsDebug = window.localStorage?.getItem('DEBUG_ANALYTICS') === '1'; const hasCustomAnalytics = analyticsEventName || analyticsEventKey || analyticsParams; if (!hasCustomAnalytics || !hasAnalyticsDebug) { return () => {}; } return () => { // eslint-disable-next-line no-console console.log('buttonAnalyticsEvent', { eventKey: analyticsEventKey, eventName: analyticsEventName, priority, href, ...analyticsParams, }); }; }; const useButtonTracking = HookStore.get('react-hook:use-button-tracking')[0] ?? useButtonTrackingLogger; const buttonTracking = useButtonTracking({ analyticsEventName, analyticsEventKey, analyticsParams: { priority, href, ...analyticsParams, }, 'aria-label': accessibleLabel || '', }); const handleClick = useCallback( (e: React.MouseEvent) => { // Don't allow clicks when disabled or busy if (disabled || busy) { e.preventDefault(); e.stopPropagation(); return; } buttonTracking(); onClick?.(e); }, [disabled, busy, onClick, buttonTracking] ); const hasChildren = Array.isArray(children) ? children.some(child => !!child) : !!children; // Buttons come in 4 flavors: , , , and