import { forwardRef, useImperativeHandle, useRef } from 'react'; import classnames from 'classnames'; import borderStyles from './Borders.css'; import styles from './ButtonToggle.css'; import ColorPicker, { SkinColor } from './ButtonToggle/ColorPicker'; import LabeledThumbnail from './ButtonToggle/LabeledThumbnail'; import { useColorScheme } from './contexts/ColorSchemeProvider'; import { useGlobalEventsHandlerContext } from './contexts/GlobalEventsHandlerProvider'; import Flex from './Flex'; import focusStyles from './Focus.css'; import Icon, { IconColor } from './Icon'; import icons from './icons/index'; import touchableStyles from './TapArea.css'; import Text from './Text'; import TextUI from './TextUI'; import useFocusVisible from './useFocusVisible'; import useTapFeedback from './useTapFeedback'; import useExperimentalTheme from './utils/useExperimentalTheme'; import useInteractiveStates from './utils/useInteractiveStates'; import useTapScaleAnimation from './utils/useTapScaleAnimation'; const DEFAULT_TEXT_COLORS = { red: 'inverse', transparent: 'default', } as const; const SIZE_NAME_TO_PIXEL = { sm: 10, md: 12, lg: 12, } as const; const textSizes: { [key: string]: '100' | '200' | '300' | '400' | '500' | '600'; } = { sm: '200', md: '300', lg: '300', }; const textSizesVR: { [key: string]: 'xs' | 'sm' | 'md'; } = { sm: 'xs', md: 'sm', lg: 'md', }; type Props = { /** * Specifies the `id` of an associated element (or elements) whose contents or visibility are controlled by ButtonToggle so that screen reader users can identify the relationship between elements. See the [Accessibility guidelines](https://gestalt.pinterest.systems/web/buttontoggle#ARIA-attributes) for details on proper usage. */ accessibilityControls?: string; /** * Indicates that ButtonToggle hides or exposes collapsible components and expose whether they are currently expanded or collapsed. See the [Accessibility guidelines](https://gestalt.pinterest.systems/web/buttontoggle#ARIA-attributes) for details on proper usage. */ accessibilityExpanded?: boolean; /** * Label for screen readers to announce ButtonToggle. See the [Accessibility guidelines](https://gestalt.pinterest.systems/web/buttontoggle#ARIA-attributes) for details on proper usage. */ accessibilityLabel?: string; /** * The background color of ButtonToggle. * * This prop also accepts an array of 4 skin tones (`skinTone1`, `skinTone2`, ..., `skinTone16`) to create a color picker. See the [Color Picker Variant](https://gestalt.pinterest.systems/web/buttontoggle#Color-Picker) for details on proper usage. */ color?: 'red' | 'transparent' | readonly [SkinColor, SkinColor, SkinColor, SkinColor]; /** * Available for testing purposes, if needed. Consider [better queries](https://testing-library.com/docs/queries/afut/#priority) before using this prop. */ dataTestId?: string; /** * Indicates if ButtonToggle is disabled. Disabled ButtonToggles are inactive and cannot be interacted with. See the [state variant](https://gestalt.pinterest.systems/web/buttontoggle#State) for details on proper usage. */ disabled?: boolean; /** * Indicates that a component controls the appearance of interactive popup elements, such as menu or dialog. See the [Accessibility guidelines](https://gestalt.pinterest.systems/web/buttontoggle#ARIA-attributes) for details on proper usage. */ hasDropdown?: boolean; /** * An icon displayed above the text to illustrate the meaning of the option selected by the ButtonToggle. */ graphicSrc?: string; /** * An icon displayed before the text to help clarify the usage of ButtonToggle. */ iconStart?: keyof typeof icons; /** * Callback invoked when ButtonToggle loses focus. */ onBlur?: (arg1: { event: React.FocusEvent }) => void; /** * Callback invoked when the user clicks (press and release) on ButtonToggle with the mouse or keyboard. */ onClick?: (arg1: { event: React.MouseEvent | React.KeyboardEvent; }) => void; /** * Callback invoked when ButtonToggle gains focus. */ onFocus?: (arg1: { event: React.FocusEvent }) => void; /** * Toggles between selected/unselected. */ selected: boolean; /** * sm: 32px, md: 40px, lg: 48px * * See the [size variant](https://gestalt.pinterest.systems/web/buttontoggle#Size) variant to learn more. */ size?: 'sm' | 'md' | 'lg'; /** * Text to render inside the ButtonToggle to convey the function and purpose of the ButtonToggle. */ text: string; }; /** * [ButtonToggle](https://gestalt.pinterest.systems/web/buttontoggle) is a larger alternative to selection components such as [Checkbox](https://gestalt.pinterest.systems/web/checkbox), [RadioGroup](https://gestalt.pinterest.systems/web/radiogroup), and [Switch](https://gestalt.pinterest.systems/web/switch). It enables users to choose between two states - selected or unselected. * * ![ButtonToggle light mode](https://raw.githubusercontent.com/pinterest/gestalt/master/playwright/visual-test/ButtonToggle.spec.ts-snapshots/ButtonToggle-chromium-darwin.png) * ![ButtonToggle dark mode](https://raw.githubusercontent.com/pinterest/gestalt/master/playwright/visual-test/ButtonToggle-dark.spec.ts-snapshots/ButtonToggle-dark-chromium-darwin.png) * */ const ButtonToggleWithForwardRef = forwardRef(function ButtonToggle( { accessibilityLabel, accessibilityExpanded, color = 'transparent', dataTestId, disabled = false, hasDropdown, graphicSrc, iconStart, onBlur, onClick, onFocus, selected, size = 'md', text, accessibilityControls, }: Props, ref, ) { if (text.length === 0 && accessibilityLabel === undefined) throw new Error('ButtonToggle: When text is empty, accessibilityLabel is required.'); const theme = useExperimentalTheme(); const innerRef = useRef(null); // When using both forwardRef and innerRef, React.useimperativehandle() allows a parent component // that renders to call inputRef.current.focus() // @ts-expect-error - TS2322 - Type 'HTMLButtonElement | null' is not assignable to type 'HTMLButtonElement'. useImperativeHandle(ref, () => innerRef.current); const { compressStyle, handleBlur, handleMouseDown, handleMouseUp, handleTouchStart, handleTouchMove, handleTouchCancel, handleTouchEnd, } = useTapFeedback({ height: innerRef?.current?.clientHeight, width: innerRef?.current?.clientWidth, }); const { isHovered, handleOnMouseEnter, handleOnMouseLeave, isFocused, handleOnFocus, handleOnBlur, } = useInteractiveStates(); const { colorSchemeName } = useColorScheme(); // We need to make a few exceptions for accessibility reasons in darkMode for red buttons const isDarkMode = colorSchemeName === 'darkMode'; const isDarkModeRed = isDarkMode && color === 'red'; const { isFocusVisible } = useFocusVisible(); const buttonToggleAnimation = useTapScaleAnimation(); const borderClasses = theme.MAIN ? { [styles.rounding200]: size === 'sm', [styles.rounding300]: size === 'md', [styles.rounding400]: size === 'lg', [styles.activeBorderVr]: !disabled, [styles.disabledBorderVr]: disabled && (color !== 'transparent' || selected), [styles.disabledTransparentBorderVr]: disabled && color === 'transparent' && !selected, } : { [styles.rounding600]: !graphicSrc, [styles.rounding300]: graphicSrc && size === 'lg', [styles.rounding200]: graphicSrc && size === 'md', [styles.rounding100]: graphicSrc && size === 'sm', }; const sharedTypeClasses = classnames( theme.MAIN ? styles.buttonVr : styles.button, borderClasses, { [focusStyles.hideOutline]: !disabled && !isFocusVisible, [focusStyles.accessibilityOutline]: !disabled && isFocusVisible && !theme.MAIN, [styles.accessibilityOutlineLightBackground]: !disabled && isFocused && isFocusVisible && theme.MAIN, }, ); // Consume GlobalEventsHandlerProvider const { buttonToggleHandlers } = useGlobalEventsHandlerContext() ?? { buttonToggleHandlers: undefined, }; const sizeStyles = classnames( theme.MAIN ? { [styles.lgVr]: size === 'lg' && !graphicSrc, [styles.mdVr]: size === 'md' && !graphicSrc, [styles.smVr]: size === 'sm' && !graphicSrc, } : { [styles.lg]: size === 'lg' && !graphicSrc, [styles.md]: size === 'md' && !graphicSrc, [styles.sm]: size === 'sm' && !graphicSrc, }, ); const parentButtonClasses = classnames(sharedTypeClasses, styles.parentButton, borderClasses, { [styles.compact]: text.length === 0, }); if (color instanceof Array) { return ( ); } const childrenDivClasses = classnames(sharedTypeClasses, sizeStyles, styles.childrenDiv, { [buttonToggleAnimation.classes]: theme.MAIN, [touchableStyles.tapTransition]: !theme.MAIN, [styles.compact]: text.length === 0, [styles.disabled]: disabled && (color !== 'red' || selected), [styles.disabledRed]: disabled && color === 'red' && !selected, [styles.disabledTransparent]: disabled && color === 'transparent' && !selected, [styles.enabled]: !disabled, [borderStyles.noBorder]: color === 'red' && !selected, [styles.selected]: !disabled && selected, [styles.selectedDisabled]: disabled && selected, [styles.thumbnailDark]: graphicSrc && isDarkMode !== selected, [styles.thumbnailDisabled]: graphicSrc && disabled, [styles.thumbnailLg]: size === 'lg' && graphicSrc, [styles.thumbnailMd]: size === 'md' && graphicSrc, [styles.thumbnailSm]: size === 'sm' && graphicSrc, [styles[color]]: !disabled && !selected, [styles.interactiveBorder]: !disabled && !selected && !isFocused && color === 'transparent' && theme.MAIN, }); const textColor = (disabled && 'disabled') || (selected && 'inverse') || (selected && 'default') || (isDarkModeRed && 'default') || DEFAULT_TEXT_COLORS[color]; const content = graphicSrc ? ( ) : ( {iconStart && ( )} {theme.MAIN ? ( {text} ) : ( {text} )} {hasDropdown && ( )} ); return ( ); }); ButtonToggleWithForwardRef.displayName = 'ButtonToggle'; export default ButtonToggleWithForwardRef;