UNPKG

@spaced-out/ui-design-system

Version:
200 lines (182 loc) 6.37 kB
// @flow strict import * as React from 'react'; import {classify} from '../../utils/classify'; import type {IconType} from '../Icon'; import {CloseIcon, Icon} from '../Icon'; import {StatusIndicator} from '../StatusIndicator'; import {Truncate} from '../Truncate'; import css from './Chip.module.css'; type ClassNames = $ReadOnly<{ wrapper?: string, icon?: string, statusIndicator?: string, }>; export const CHIP_SEMANTIC = Object.freeze({ primary: 'primary', information: 'information', success: 'success', warning: 'warning', danger: 'danger', secondary: 'secondary', }); export type ChipSemanticType = $Values<typeof CHIP_SEMANTIC>; export type BaseChipProps = { classNames?: ClassNames, semantic?: ChipSemanticType, children: React.Node, disabled?: boolean, showStatusIndicator?: boolean, disableHoverState?: boolean, line?: number, wordBreak?: string, onClick?: ?(SyntheticEvent<HTMLElement>) => mixed, onMouseEnter?: ?(SyntheticEvent<HTMLElement>) => mixed, onMouseLeave?: ?(SyntheticEvent<HTMLElement>) => mixed, }; export type LargeChipProps = { ...BaseChipProps, iconName?: string, iconType?: IconType, dismissable?: boolean, onDismiss?: ?(SyntheticEvent<HTMLElement>) => mixed, size?: 'large', }; export type MediumChipProps = { ...LargeChipProps, size?: 'medium', }; export type SmallChipProps = { ...BaseChipProps, size?: 'small', }; export type ChipProps = LargeChipProps | MediumChipProps | SmallChipProps; export const Chip: React$AbstractComponent<ChipProps, HTMLDivElement> = React.forwardRef<ChipProps, HTMLDivElement>( ( { classNames, semantic = 'primary', size = 'medium', children, iconName = '', iconType = 'regular', showStatusIndicator, dismissable = false, onDismiss = () => null, onClick, disabled, line = 1, wordBreak, disableHoverState = !onClick, // There is no reason for hover state to be active when there is no click handler attached ...restProps }: ChipProps, ref, ): React.Node => { /** * Note (Nishant): Why we are using a `div` to render a onclick element instead of a `button`? * * Rendering the `Chip` component as a button directly would have been ideal, as it would * have naturally handled interactivity and accessibility for clickable chips(which has an onClick). However, * the `Chip` component includes a `CloseIcon`, which itself is a button. Nesting a `<button>` * inside another `<button>` is semantically incorrect and would lead to improper HTML structure. * * Instead, we use a `<div>` with `role="button"` to maintain proper semantic behavior and * avoid nesting buttons. While `role="button"` provides the appropriate semantics, it does * not automatically handle keyboard interactions. Therefore, we manually handle `Enter` * and `Space` key events to ensure the component is fully accessible and keyboard-compliant. * * Although this method might seem less conventional, it simplifies implementation and ensures * backward compatibility while adhering to accessibility standards. */ const handleKeyDown = (event: SyntheticKeyboardEvent<HTMLElement>) => { const {key} = event; if (key === 'Enter' || key === ' ') { event.preventDefault(); // Prevent default action for Enter and Space keys onClick?.(event); } }; return ( <div data-testid="Chip" {...restProps} ref={ref} className={classify( css.chipWrapper, { [css.primary]: semantic === CHIP_SEMANTIC.primary, [css.information]: semantic === CHIP_SEMANTIC.information, [css.success]: semantic === CHIP_SEMANTIC.success, [css.warning]: semantic === CHIP_SEMANTIC.warning, [css.danger]: semantic === CHIP_SEMANTIC.danger, [css.secondary]: semantic === CHIP_SEMANTIC.secondary, [css.large]: size === 'large', [css.medium]: size === 'medium', [css.small]: size === 'small', [css.dismissable]: dismissable, [css.withIcon]: !!iconName && size !== 'small', [css.disabled]: disabled, [css.noHoverState]: showStatusIndicator || disableHoverState || disabled, }, classNames?.wrapper, )} onClick={onClick} onKeyDown={handleKeyDown} tabIndex={ showStatusIndicator || disableHoverState || disabled ? undefined : 0 } role={showStatusIndicator || disableHoverState ? undefined : 'button'} > {showStatusIndicator && size !== 'small' && ( <StatusIndicator status={semantic} classNames={{ wrapper: classify( css.statusIndicatorBlock, classNames?.statusIndicator, ), }} disabled={disabled} /> )} {iconName && size !== 'small' && ( <Icon className={classify( css.chipIcon, {[css.alignTop]: line > 1}, classNames?.icon, )} name={iconName} type={iconType} size="small" /> )} <Truncate line={line} wordBreak={wordBreak}> {children} </Truncate> {dismissable && size !== 'small' && ( <CloseIcon classNames={{ icon: css.dismissIcon, button: classify( {[css.alignTop]: line > 1}, css.dismissIconWrapper, ), }} type={iconType} size="small" ariaLabel="Dismiss" disabled={disabled} onClick={(event) => { event.stopPropagation(); if (!disabled && onDismiss) { onDismiss(event); } }} /> )} </div> ); }, ); Chip.displayName = 'Chip';