@spaced-out/ui-design-system
Version:
Sense UI components library
200 lines (182 loc) • 6.37 kB
Flow
// @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';